api_keys 0.3.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +101 -39
  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 +40 -18
  7. data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
  8. data/app/views/api_keys/keys/_form.html.erb +3 -3
  9. data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
  10. data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
  11. data/app/views/api_keys/keys/_key_row.html.erb +1 -1
  12. data/app/views/api_keys/keys/_key_status.html.erb +3 -3
  13. data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
  14. data/app/views/api_keys/keys/_show_token.html.erb +5 -46
  15. data/app/views/api_keys/keys/_token_display.html.erb +3 -3
  16. data/app/views/api_keys/keys/index.html.erb +2 -2
  17. data/app/views/api_keys/keys/show.html.erb +2 -2
  18. data/app/views/api_keys/security/best_practices.html.erb +7 -7
  19. data/app/views/layouts/api_keys/application.html.erb +159 -12
  20. data/lib/api_keys/authentication.rb +39 -11
  21. data/lib/api_keys/configuration.rb +374 -24
  22. data/lib/api_keys/engine.rb +5 -20
  23. data/lib/api_keys/form_builder_extensions.rb +12 -2
  24. data/lib/api_keys/helpers/expiration_options.rb +11 -3
  25. data/lib/api_keys/helpers/token_session.rb +143 -8
  26. data/lib/api_keys/helpers/view_helpers.rb +5 -1
  27. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  28. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  29. data/lib/api_keys/models/api_key.rb +244 -25
  30. data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
  31. data/lib/api_keys/services/authenticator.rb +263 -118
  32. data/lib/api_keys/services/digestor.rb +76 -13
  33. data/lib/api_keys/services/token_generator.rb +41 -1
  34. data/lib/api_keys/tenant_resolution.rb +2 -4
  35. data/lib/api_keys/version.rb +1 -1
  36. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  37. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
  39. data/lib/generators/api_keys/templates/initializer.rb +36 -17
  40. metadata +16 -16
  41. data/.simplecov +0 -36
  42. data/AGENTS.md +0 -5
  43. data/Appraisals +0 -17
  44. data/CLAUDE.md +0 -5
  45. data/Rakefile +0 -37
  46. data/context7.json +0 -4
  47. data/gemfiles/rails_7.2.gemfile +0 -21
  48. data/gemfiles/rails_8.0.gemfile +0 -21
  49. data/gemfiles/rails_8.1.gemfile +0 -21
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record"
4
+ require "json"
4
5
  require_relative "../services/token_generator"
5
6
  require_relative "../services/digestor"
6
7
 
7
8
  module ApiKeys
8
9
  # The core ActiveRecord model representing an API key.
9
10
  class ApiKey < ActiveRecord::Base
11
+ MAX_SCOPES = 100
12
+ MAX_SCOPE_BYTESIZE = 128
13
+ MAX_METADATA_BYTESIZE = 16_384
14
+ IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
15
+ token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
16
+ ].freeze
17
+
10
18
  self.table_name = "api_keys"
11
19
 
12
20
  # == Concerns ==
@@ -27,7 +35,10 @@ module ApiKeys
27
35
  # Works for both create and update operations.
28
36
  def scopes=(value)
29
37
  cleaned = if value.is_a?(Array)
30
- value.reject { |s| s.blank? }
38
+ value
39
+ .map { |scope| scope.is_a?(Symbol) ? scope.to_s : scope }
40
+ .reject { |scope| scope.respond_to?(:blank?) && scope.blank? }
41
+ .uniq
31
42
  else
32
43
  value
33
44
  end
@@ -36,8 +47,8 @@ module ApiKeys
36
47
 
37
48
  # == Validations ==
38
49
  validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
39
- validates :prefix, presence: true
40
- validates :digest_algorithm, presence: true
50
+ validates :prefix, presence: true, length: { maximum: 64 }
51
+ validates :digest_algorithm, presence: true, inclusion: { in: %w[sha256 bcrypt] }
41
52
  validates :last4, presence: true, length: { is: 4 }
42
53
  # validates :scopes, presence: true # Default handled by attribute def
43
54
  # validates :metadata, presence: true # Default handled by attribute def
@@ -54,6 +65,15 @@ module ApiKeys
54
65
  validate :expiration_date_cannot_be_in_the_past, if: :expires_at?
55
66
  validate :within_key_type_limit, on: :create, if: -> { key_type.present? && owner.present? }
56
67
  validate :non_revocable_keys_cannot_expire, if: -> { key_type.present? && expires_at.present? }
68
+ validate :scopes_are_well_formed
69
+ validate :scopes_respect_permission_ceiling
70
+ validate :key_type_present_when_feature_enabled, on: :create
71
+ validate :key_type_is_configured, if: -> { key_type.present? }
72
+ validate :environment_is_configured, if: -> { key_type.present? }
73
+ validate :token_digest_matches_algorithm
74
+ validate :token_identifiers_are_well_formed
75
+ validate :metadata_is_well_formed
76
+ validate :authentication_identity_is_immutable, on: :update
57
77
 
58
78
  # TODO: Add validation for scope string format
59
79
  # TODO: Add validation for prefix format (e.g., must end with _)
@@ -62,6 +82,10 @@ module ApiKeys
62
82
  before_validation :set_defaults, on: :create
63
83
  # Generate digest BEFORE validation runs
64
84
  before_validation :generate_token_and_digest, on: :create
85
+ # Serialize quota validation and insertion for every creation path, including
86
+ # direct ApiKey.create! calls that do not use HasApiKeys#create_api_key!.
87
+ before_validation :lock_owner_for_creation, on: :create
88
+ after_commit :clear_known_prefixes_cache, on: :create
65
89
 
66
90
  # == Scopes ==
67
91
  scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
@@ -132,26 +156,68 @@ module ApiKeys
132
156
  !revoked? && !expired?
133
157
  end
134
158
 
159
+ # The plaintext token is an ephemeral creation-time value. Active Record's
160
+ # reload does not clear arbitrary instance variables, so clear it explicitly.
161
+ def reload(...)
162
+ @token = nil
163
+ super
164
+ end
165
+
166
+ # Keep credentials and credential-derived values out of logs and consoles.
167
+ def inspect
168
+ attributes = %w[id prefix last4 name owner_type owner_id key_type environment expires_at revoked_at]
169
+ .select { |attribute_name| has_attribute?(attribute_name) }
170
+ .map { |attribute_name| "#{attribute_name}: #{attribute_for_inspect(attribute_name)}" }
171
+ "#<#{self.class.name} #{attributes.join(', ')}>"
172
+ rescue StandardError
173
+ "#<#{self.class.name}>"
174
+ end
175
+
176
+ def pretty_print(printer)
177
+ printer.text(inspect)
178
+ end
179
+
180
+ # Rendering a model as JSON must never expose the verification digest or a
181
+ # public token stored in the reserved metadata field. Call #viewable_token
182
+ # explicitly when an authenticated UI intentionally needs a public token.
183
+ def serializable_hash(options = nil)
184
+ serialized = super(options)
185
+ serialized.delete("token_digest")
186
+ serialized.delete(:token_digest)
187
+
188
+ metadata_value = serialized["metadata"] || serialized[:metadata]
189
+ if metadata_value.is_a?(Hash)
190
+ sanitized_metadata = metadata_value.dup
191
+ sanitized_metadata.delete("token")
192
+ sanitized_metadata.delete(:token)
193
+ serialized[serialized.key?("metadata") ? "metadata" : :metadata] = sanitized_metadata
194
+ end
195
+
196
+ serialized
197
+ end
198
+
135
199
  # Returns true if this key can be revoked/destroyed
136
200
  # Keys without a key_type (legacy) are always revocable
137
201
  # Keys with a key_type check the configuration
138
202
  def revocable?
139
203
  return true if key_type.blank?
140
204
  config = key_type_config
141
- return true if config.nil?
205
+ return false if config.nil?
142
206
  config.fetch(:revocable, true)
143
207
  end
144
208
 
145
209
  # Returns the configuration hash for this key's type
146
210
  def key_type_config
147
211
  return nil if key_type.blank?
148
- ApiKeys.configuration.key_types&.dig(key_type.to_sym)
212
+ configured_pair = ApiKeys.configuration.key_types&.find { |type, _settings| type.to_s == key_type.to_s }
213
+ configured_pair&.last
149
214
  end
150
215
 
151
216
  # Returns the configuration hash for this key's environment
152
217
  def environment_config
153
218
  return nil if environment.blank?
154
- ApiKeys.configuration.environments&.dig(environment.to_sym)
219
+ configured_pair = ApiKeys.configuration.environments&.find { |name, _settings| name.to_s == environment.to_s }
220
+ configured_pair&.last
155
221
  end
156
222
 
157
223
  # Returns true if this key type is configured as public AND non-revocable.
@@ -192,15 +258,20 @@ module ApiKeys
192
258
  # key types with permission ceilings, an empty scope list should deny access,
193
259
  # not silently bypass the entire permission system.
194
260
  def allows_scope?(required_scope)
195
- return true unless respond_to?(:scopes) # Guard clause if loaded before attribute definition
261
+ return false unless respond_to?(:scopes)
262
+ return false unless required_scope.present?
263
+ return false unless scopes.is_a?(Array)
196
264
 
197
265
  if scopes.blank?
198
- # In key_types mode, blank scopes = no access (deny by default)
199
- # In simple mode, blank scopes = unrestricted (allow by default)
200
- return !ApiKeys.configuration.key_types.present?
266
+ return !scope_policy_enabled?
201
267
  end
202
268
 
203
- scopes.include?(required_scope.to_s)
269
+ required = required_scope.to_s
270
+ return false unless scopes.all? { |scope| valid_scope_value?(scope) }
271
+ return false unless scopes.include?(required)
272
+
273
+ ceiling = permission_ceiling
274
+ ceiling == :all || ceiling.include?(required)
204
275
  end
205
276
 
206
277
  # Alias for scopes - provides a more user-friendly API that matches
@@ -299,7 +370,8 @@ module ApiKeys
299
370
 
300
371
  # Safety check: Ensure generated token starts with the expected prefix
301
372
  unless @token.start_with?(self.prefix)
302
- raise ApiKeys::Error, "Generated token '#{@token}' does not match expected prefix '#{self.prefix}'. Check TokenGenerator."
373
+ @token = nil
374
+ raise ApiKeys::Error, "Generated token does not match the configured prefix. Check TokenGenerator configuration."
303
375
  end
304
376
 
305
377
  # Use the configured digestor
@@ -329,6 +401,163 @@ module ApiKeys
329
401
 
330
402
  # == Validation Helpers ==
331
403
 
404
+ def lock_owner_for_creation
405
+ return unless owner&.persisted?
406
+
407
+ # Query a separate relation so locking does not reload or discard unsaved
408
+ # attributes on the caller's in-memory owner object. `unscoped` ensures a
409
+ # tenant/default scope cannot accidentally bypass quota serialization.
410
+ owner.class.unscoped.lock(true).find(owner.id)
411
+ end
412
+
413
+ def scopes_are_well_formed
414
+ unless scopes.is_a?(Array)
415
+ errors.add(:scopes, "must be an array")
416
+ return
417
+ end
418
+
419
+ if scopes.length > MAX_SCOPES
420
+ errors.add(:scopes, "cannot contain more than #{MAX_SCOPES} entries")
421
+ end
422
+
423
+ unless scopes.all? { |scope| valid_scope_value?(scope) }
424
+ errors.add(:scopes, "must contain only non-blank strings of at most #{MAX_SCOPE_BYTESIZE} bytes without whitespace or control characters")
425
+ end
426
+ end
427
+
428
+ def token_digest_matches_algorithm
429
+ return if token_digest.blank? || digest_algorithm.blank?
430
+
431
+ valid = case digest_algorithm.to_s
432
+ when "sha256"
433
+ token_digest.is_a?(String) && token_digest.match?(/\A\h{64}\z/)
434
+ when "bcrypt"
435
+ ApiKeys::Services::Digestor.valid_bcrypt_digest?(token_digest)
436
+ else
437
+ true # The inclusion validation reports unsupported algorithms.
438
+ end
439
+ errors.add(:token_digest, "is not a valid #{digest_algorithm} digest") unless valid
440
+ end
441
+
442
+ def token_identifiers_are_well_formed
443
+ unless safe_token_component?(prefix, maximum_bytes: 64)
444
+ errors.add(:prefix, "must not contain whitespace or control characters")
445
+ end
446
+ unless safe_token_component?(last4, maximum_bytes: 4)
447
+ errors.add(:last4, "must not contain whitespace or control characters")
448
+ end
449
+
450
+ %i[key_type environment].each do |attribute_name|
451
+ value = public_send(attribute_name)
452
+ next if value.blank?
453
+ next if value.is_a?(String) && value.bytesize <= 64 && value.match?(/\A[a-zA-Z0-9_-]+\z/)
454
+
455
+ errors.add(attribute_name, "must contain only letters, numbers, underscores, or hyphens (maximum 64 bytes)")
456
+ end
457
+ end
458
+
459
+ def metadata_is_well_formed
460
+ unless metadata.is_a?(Hash)
461
+ errors.add(:metadata, "must be an object")
462
+ return
463
+ end
464
+
465
+ errors.add(:metadata, "is too large") if JSON.generate(metadata).bytesize > MAX_METADATA_BYTESIZE
466
+ rescue JSON::GeneratorError, EncodingError
467
+ errors.add(:metadata, "must contain valid JSON data")
468
+ end
469
+
470
+ def authentication_identity_is_immutable
471
+ IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name|
472
+ next unless will_save_change_to_attribute?(attribute_name)
473
+
474
+ errors.add(attribute_name, "cannot be changed after creation")
475
+ end
476
+ end
477
+
478
+ def scopes_respect_permission_ceiling
479
+ return unless scopes.is_a?(Array)
480
+
481
+ ceiling = permission_ceiling
482
+ return if ceiling == :all
483
+ return if scopes.all? { |scope| ceiling.include?(scope) }
484
+
485
+ errors.add(:scopes, "exceed the configured permission ceiling")
486
+ end
487
+
488
+ def key_type_is_configured
489
+ return if key_type_config
490
+
491
+ errors.add(:key_type, "is not configured")
492
+ end
493
+
494
+ def key_type_present_when_feature_enabled
495
+ return unless key_types_feature_enabled?
496
+ return if key_type.present?
497
+
498
+ errors.add(:key_type, "must be present when key types are configured")
499
+ end
500
+
501
+ def environment_is_configured
502
+ if environment.blank?
503
+ errors.add(:environment, "must be present for typed API keys")
504
+ return
505
+ end
506
+
507
+ configured_environments = ApiKeys.configuration.environments
508
+ return if configured_environments.blank? || environment_config
509
+
510
+ errors.add(:environment, "is not configured")
511
+ end
512
+
513
+ def valid_scope_value?(scope)
514
+ scope.is_a?(String) && scope.present? && scope.valid_encoding? &&
515
+ scope.bytesize <= MAX_SCOPE_BYTESIZE &&
516
+ scope.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
517
+ rescue ArgumentError
518
+ false
519
+ end
520
+
521
+ def safe_token_component?(value, maximum_bytes:)
522
+ value.is_a?(String) && value.present? && value.valid_encoding? && value.bytesize <= maximum_bytes &&
523
+ value.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
524
+ rescue ArgumentError
525
+ false
526
+ end
527
+
528
+ def permission_ceiling
529
+ if key_type.present?
530
+ config = key_type_config
531
+ return [] unless config
532
+
533
+ permissions = config[:permissions]
534
+ return :all if permissions == :all
535
+
536
+ return Array(permissions).map(&:to_s)
537
+ end
538
+
539
+ configured_simple_scopes = simple_scope_configuration
540
+ configured_simple_scopes.present? ? configured_simple_scopes : :all
541
+ end
542
+
543
+ def scope_policy_enabled?
544
+ key_type.present? || ApiKeys.configuration.key_types.present? || simple_scope_configuration.present?
545
+ end
546
+
547
+ def simple_scope_configuration
548
+ owner_scopes = if owner&.class.respond_to?(:api_keys_settings)
549
+ owner.class.api_keys_settings&.[](:default_scopes)
550
+ end
551
+ configured = owner_scopes.presence || ApiKeys.configuration.default_scopes
552
+ Array(configured).map(&:to_s)
553
+ end
554
+
555
+ def clear_known_prefixes_cache
556
+ return unless defined?(ApiKeys::Services::Authenticator)
557
+
558
+ ApiKeys::Services::Authenticator.clear_known_prefixes_cache
559
+ end
560
+
332
561
  def owner_present_and_configured?
333
562
  owner.present? && owner_configured?
334
563
  end
@@ -385,9 +614,8 @@ module ApiKeys
385
614
  end
386
615
  end
387
616
 
388
- # Check if creating this key would exceed the limit for this key type/environment
389
- # Uses row-level locking to prevent race conditions when multiple requests
390
- # try to create keys concurrently.
617
+ # Check if creating this key would exceed the limit for this key type/environment.
618
+ # HasApiKeys#create_api_key! locks the owner row around this validation and insert.
391
619
  def within_key_type_limit
392
620
  return unless key_types_feature_enabled?
393
621
 
@@ -397,20 +625,11 @@ module ApiKeys
397
625
  limit = config[:limit]
398
626
  return unless limit # nil limit = unlimited
399
627
 
400
- # Use pessimistic locking to prevent race conditions.
401
- # Lock the owner's existing keys of this type/environment while counting.
402
- # This ensures atomic check-then-create semantics.
403
- #
404
- # Note: We use .ids.size instead of .count because PostgreSQL doesn't allow
405
- # FOR UPDATE with aggregate functions (COUNT). By selecting IDs with the
406
- # lock and counting in Ruby, we achieve the same race condition protection.
407
628
  existing_count = owner.api_keys
408
629
  .active
409
630
  .where(key_type: key_type.to_s)
410
631
  .where(environment: environment.to_s)
411
- .lock(true)
412
- .ids
413
- .size
632
+ .count
414
633
 
415
634
  if existing_count >= limit
416
635
  errors.add(:base, "Maximum number of #{key_type} keys (#{limit}) reached for #{environment} environment")
@@ -10,6 +10,50 @@ module ApiKeys
10
10
  module HasApiKeys
11
11
  extend ActiveSupport::Concern
12
12
 
13
+ SUPPORTED_SETTINGS = %i[max_keys require_name default_scopes].freeze
14
+ MAX_DEFAULT_SCOPES = 100
15
+ MAX_SCOPE_BYTESIZE = 128
16
+
17
+ class << self
18
+ def validate_and_freeze_settings(settings)
19
+ max_keys = settings[:max_keys]
20
+ unless max_keys.nil? || (max_keys.is_a?(Integer) && max_keys >= 0)
21
+ raise ArgumentError, "max_keys must be a non-negative Integer or nil"
22
+ end
23
+
24
+ require_name = settings[:require_name]
25
+ unless require_name == true || require_name == false
26
+ raise ArgumentError, "require_name must be true or false"
27
+ end
28
+
29
+ scopes = settings[:default_scopes]
30
+ unless scopes.is_a?(Array) && scopes.length <= MAX_DEFAULT_SCOPES
31
+ raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
32
+ end
33
+
34
+ normalized_scopes = scopes.map { |scope| scope.is_a?(Symbol) ? scope.to_s : scope }
35
+ unless normalized_scopes.all? { |scope| valid_scope_name?(scope) }
36
+ raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
37
+ end
38
+
39
+ {
40
+ max_keys: max_keys,
41
+ require_name: require_name,
42
+ default_scopes: normalized_scopes.uniq.map { |scope| scope.dup.freeze }.freeze
43
+ }.freeze
44
+ end
45
+
46
+ private
47
+
48
+ def valid_scope_name?(scope)
49
+ scope.is_a?(String) && scope.present? && scope.valid_encoding? &&
50
+ scope.bytesize <= MAX_SCOPE_BYTESIZE &&
51
+ scope.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+ end
56
+
13
57
  # Module containing class methods to be extended onto ActiveRecord::Base
14
58
  module ClassMethods
15
59
  # Defines the association and allows configuration for the specific owner model.
@@ -27,20 +71,9 @@ module ApiKeys
27
71
  # end
28
72
  # end
29
73
  def has_api_keys(**options, &block)
30
- # Include the concern's instance methods into the calling class (e.g., User)
31
- # Ensures any instance-level helpers in HasApiKeys are available on the owner.
32
- include ApiKeys::Models::Concerns::HasApiKeys unless included_modules.include?(ApiKeys::Models::Concerns::HasApiKeys)
33
-
34
- # Define the core association on the specific class calling this method
35
- has_many :api_keys,
36
- class_name: "ApiKeys::ApiKey",
37
- as: :owner,
38
- dependent: :destroy # Consider :nullify based on requirements
39
-
40
- # Define class_attribute for settings if not already defined.
41
- # This ensures inheritance works correctly (subclasses get their own copy).
42
- unless respond_to?(:api_keys_settings)
43
- class_attribute :api_keys_settings, instance_writer: false, default: {}
74
+ unknown_settings = options.keys - SUPPORTED_SETTINGS
75
+ if unknown_settings.any?
76
+ raise ArgumentError, "Unknown has_api_keys setting(s): #{unknown_settings.join(', ')}"
44
77
  end
45
78
 
46
79
  # Initialize settings for this specific class, merging defaults and options
@@ -57,8 +90,30 @@ module ApiKeys
57
90
  dsl.instance_eval(&block)
58
91
  end
59
92
 
60
- # Assign the final settings hash to the class attribute for this class
61
- self.api_keys_settings = current_settings
93
+ validated_settings = HasApiKeys.validate_and_freeze_settings(current_settings)
94
+
95
+ # Include the concern's instance methods into the calling class (e.g., User)
96
+ # Ensures any instance-level helpers in HasApiKeys are available on the owner.
97
+ include ApiKeys::Models::Concerns::HasApiKeys unless included_modules.include?(ApiKeys::Models::Concerns::HasApiKeys)
98
+
99
+ # Define the core association on the specific class calling this method
100
+ has_many :api_keys,
101
+ class_name: "ApiKeys::ApiKey",
102
+ as: :owner,
103
+ # An owner deletion is an administrative lifecycle event and
104
+ # must remove every credential, including key types that users
105
+ # cannot revoke individually through the normal API.
106
+ dependent: :delete_all
107
+
108
+ # Define class_attribute for settings if not already defined.
109
+ # This ensures inheritance works correctly (subclasses get their own copy).
110
+ unless respond_to?(:api_keys_settings)
111
+ class_attribute :api_keys_settings, instance_writer: false, default: {}
112
+ end
113
+
114
+ # Assign an immutable copy so later mutations cannot silently change
115
+ # quota, naming, or permission policy at runtime.
116
+ self.api_keys_settings = validated_settings
62
117
 
63
118
  # TODO: Add validation hook to check key limit on create?
64
119
  # validates_with ApiKeys::Validators::MaxKeysValidator, on: :create, if: -> { api_keys_settings[:max_keys].present? }
@@ -133,7 +188,7 @@ module ApiKeys
133
188
  resolved_environment = resolve_environment(environment, key_type, config)
134
189
 
135
190
  # Get type-specific limit
136
- type_config = config.key_types&.dig(key_type.to_sym)
191
+ type_config = config.key_types&.find { |type, _settings| type.to_s == key_type.to_s }&.last
137
192
  return within_global_quota? unless type_config
138
193
 
139
194
  limit = type_config[:limit]
@@ -194,6 +249,8 @@ module ApiKeys
194
249
  # Validate key_type if provided and key_types feature is enabled
195
250
  if resolved_key_type.present?
196
251
  validate_key_type!(resolved_key_type, config)
252
+ elsif key_types_feature_enabled?(config)
253
+ raise ArgumentError, "key_type is required when key types are configured"
197
254
  end
198
255
 
199
256
  # Determine environment: use provided, or default from config
@@ -216,16 +273,22 @@ module ApiKeys
216
273
  key_scopes = filter_scopes_by_permissions(key_scopes, resolved_key_type, config)
217
274
  end
218
275
 
219
- # Create the key using the association, letting AR handle owner_id/type.
220
- api_key = self.api_keys.create!(
221
- name: name,
222
- scopes: key_scopes,
223
- expires_at: expires_at,
224
- metadata: metadata || {}, # Ensure metadata is at least an empty hash
225
- key_type: resolved_key_type&.to_s,
226
- environment: resolved_environment&.to_s
227
- # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
228
- )
276
+ raise ArgumentError, "API key owner must be persisted before creating a key" unless persisted?
277
+
278
+ # ApiKey's creation callback locks the owner row before quota validation.
279
+ # Keep an explicit transaction here so the helper's creation workflow is
280
+ # a single atomic unit; direct ApiKey.create! calls are protected too.
281
+ api_key = self.class.transaction do
282
+ self.api_keys.create!(
283
+ name: name,
284
+ scopes: key_scopes,
285
+ expires_at: expires_at,
286
+ metadata: metadata || {}, # Ensure metadata is at least an empty hash
287
+ key_type: resolved_key_type&.to_s,
288
+ environment: resolved_environment&.to_s
289
+ # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
290
+ )
291
+ end
229
292
 
230
293
  # Return the ApiKey instance itself.
231
294
  # The plaintext token is available via `api_key.token` immediately after this.
@@ -249,8 +312,8 @@ module ApiKeys
249
312
  def validate_key_type!(key_type, config)
250
313
  return unless key_types_feature_enabled?(config)
251
314
 
252
- valid_types = config.key_types.keys.map(&:to_sym)
253
- unless valid_types.include?(key_type.to_sym)
315
+ valid_types = config.key_types.keys.map(&:to_s)
316
+ unless valid_types.include?(key_type.to_s)
254
317
  raise ArgumentError, "Invalid key type '#{key_type}'. Valid types: #{valid_types.join(', ')}"
255
318
  end
256
319
  end
@@ -258,8 +321,8 @@ module ApiKeys
258
321
  def validate_environment!(environment, config)
259
322
  return unless config.environments.present? && config.environments.any?
260
323
 
261
- valid_environments = config.environments.keys.map(&:to_sym)
262
- unless valid_environments.include?(environment.to_sym)
324
+ valid_environments = config.environments.keys.map(&:to_s)
325
+ unless valid_environments.include?(environment.to_s)
263
326
  raise ArgumentError, "Invalid environment '#{environment}'. Valid environments: #{valid_environments.join(', ')}"
264
327
  end
265
328
  end
@@ -280,7 +343,7 @@ module ApiKeys
280
343
  def filter_scopes_by_permissions(scopes, key_type, config)
281
344
  return scopes unless key_types_feature_enabled?(config)
282
345
 
283
- type_config = config.key_types[key_type.to_sym]
346
+ type_config = config.key_types.find { |type, _settings| type.to_s == key_type.to_s }&.last
284
347
  return scopes unless type_config
285
348
 
286
349
  permissions = type_config[:permissions]