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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +101 -39
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +374 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
- data/lib/generators/api_keys/templates/initializer.rb +36 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- 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
|
|
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
|
|
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&.
|
|
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&.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
.
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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&.
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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(&:
|
|
253
|
-
unless valid_types.include?(key_type.
|
|
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(&:
|
|
262
|
-
unless valid_environments.include?(environment.
|
|
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
|
|
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]
|