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,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 ==
@@ -22,10 +30,25 @@ module ApiKeys
22
30
  # JSON attributes (:scopes, :metadata) are defined in the engine initializer
23
31
  # using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
24
32
 
33
+ # Override scopes setter to auto-clean blank values.
34
+ # This handles the common case where form checkboxes submit empty strings.
35
+ # Works for both create and update operations.
36
+ def scopes=(value)
37
+ cleaned = if value.is_a?(Array)
38
+ value
39
+ .map { |scope| scope.is_a?(Symbol) ? scope.to_s : scope }
40
+ .reject { |scope| scope.respond_to?(:blank?) && scope.blank? }
41
+ .uniq
42
+ else
43
+ value
44
+ end
45
+ super(cleaned)
46
+ end
47
+
25
48
  # == Validations ==
26
49
  validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
27
- validates :prefix, presence: true
28
- validates :digest_algorithm, presence: true
50
+ validates :prefix, presence: true, length: { maximum: 64 }
51
+ validates :digest_algorithm, presence: true, inclusion: { in: %w[sha256 bcrypt] }
29
52
  validates :last4, presence: true, length: { is: 4 }
30
53
  # validates :scopes, presence: true # Default handled by attribute def
31
54
  # validates :metadata, presence: true # Default handled by attribute def
@@ -40,6 +63,17 @@ module ApiKeys
40
63
 
41
64
  # TODO: Add validation for expires_at > Time.current if present
42
65
  validate :expiration_date_cannot_be_in_the_past, if: :expires_at?
66
+ validate :within_key_type_limit, on: :create, if: -> { key_type.present? && owner.present? }
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
43
77
 
44
78
  # TODO: Add validation for scope string format
45
79
  # TODO: Add validation for prefix format (e.g., must end with _)
@@ -48,6 +82,10 @@ module ApiKeys
48
82
  before_validation :set_defaults, on: :create
49
83
  # Generate digest BEFORE validation runs
50
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
51
89
 
52
90
  # == Scopes ==
53
91
  scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
@@ -56,11 +94,53 @@ module ApiKeys
56
94
  scope :inactive, -> { revoked.or(expired) }
57
95
  scope :for_prefix, ->(prefix) { where(prefix: prefix) }
58
96
  scope :for_owner, ->(owner) { where(owner: owner) }
59
- # TODO: Add more scopes as needed (e.g., for_owner)
97
+ scope :for_key_type, ->(key_type) { where(key_type: key_type.to_s) }
98
+ scope :for_environment, ->(environment) { where(environment: environment.to_s) }
99
+
100
+ # Convenience scopes for key types
101
+ # .publishable returns only keys with key_type: "publishable"
102
+ # .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type)
103
+ scope :publishable, -> { where(key_type: "publishable") }
104
+ scope :secret, -> { where.not(key_type: "publishable") }
105
+
106
+ # === Usage Analytics Scopes ===
107
+ # These scopes help admin dashboards analyze API key usage patterns.
108
+ # Useful for identifying unused keys, high-traffic keys, and stale keys that may need cleanup.
109
+
110
+ # Keys that have never been used (last_used_at is nil)
111
+ scope :never_used, -> { where(last_used_at: nil) }
112
+
113
+ # Keys that have been used at least once
114
+ scope :used, -> { where.not(last_used_at: nil) }
115
+
116
+ # Order by usage count (highest first) - useful for finding most active keys
117
+ scope :by_requests, -> { order(requests_count: :desc) }
118
+
119
+ # Order by last used time (most recent first, nulls last)
120
+ # Uses NULLS LAST for PostgreSQL compatibility; SQLite sorts nulls last by default with DESC
121
+ scope :by_last_used, -> { order(Arel.sql("CASE WHEN last_used_at IS NULL THEN 1 ELSE 0 END, last_used_at DESC")) }
122
+
123
+ # Active keys that haven't been used within the specified period.
124
+ # Useful for identifying keys that may have been abandoned or forgotten.
125
+ # Excludes revoked/expired keys since those are already inactive.
126
+ # @param period [ActiveSupport::Duration] The inactivity threshold (default: 30 days)
127
+ scope :stale, ->(period = 30.days) {
128
+ active.where("last_used_at < :threshold OR last_used_at IS NULL", threshold: period.ago)
129
+ }
130
+
131
+ # Aliases for common admin dashboard naming conventions
132
+ class << self
133
+ alias_method :most_used, :by_requests
134
+ alias_method :recently_used, :by_last_used
135
+ end
136
+
137
+ # Convenience scope for 30-day stale keys (common admin filter)
138
+ scope :inactive_for_30_days, -> { stale(30.days) }
60
139
 
61
140
  # == Instance Methods ==
62
141
 
63
142
  def revoke!
143
+ raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
64
144
  update!(revoked_at: Time.current)
65
145
  end
66
146
 
@@ -76,14 +156,138 @@ module ApiKeys
76
156
  !revoked? && !expired?
77
157
  end
78
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
+
199
+ # Returns true if this key can be revoked/destroyed
200
+ # Keys without a key_type (legacy) are always revocable
201
+ # Keys with a key_type check the configuration
202
+ def revocable?
203
+ return true if key_type.blank?
204
+ config = key_type_config
205
+ return false if config.nil?
206
+ config.fetch(:revocable, true)
207
+ end
208
+
209
+ # Returns the configuration hash for this key's type
210
+ def key_type_config
211
+ return nil if key_type.blank?
212
+ configured_pair = ApiKeys.configuration.key_types&.find { |type, _settings| type.to_s == key_type.to_s }
213
+ configured_pair&.last
214
+ end
215
+
216
+ # Returns the configuration hash for this key's environment
217
+ def environment_config
218
+ return nil if environment.blank?
219
+ configured_pair = ApiKeys.configuration.environments&.find { |name, _settings| name.to_s == environment.to_s }
220
+ configured_pair&.last
221
+ end
222
+
223
+ # Returns true if this key type is configured as public AND non-revocable.
224
+ # Only these keys have their plaintext token stored in metadata for later viewing.
225
+ # This is used for publishable keys that are designed to be embedded in distributed apps.
226
+ def public_key_type?
227
+ return false if key_type.blank?
228
+ config = key_type_config
229
+ return false if config.nil?
230
+ config[:public] == true && config[:revocable] == false
231
+ end
232
+
233
+ # Returns the stored plaintext token for public, non-revocable keys.
234
+ # Returns nil for all other key types (the token is only available at creation time).
235
+ # @return [String, nil] The full plaintext token, or nil if not stored
236
+ def viewable_token
237
+ return nil unless public_key_type?
238
+ metadata&.dig("token")
239
+ end
240
+
241
+ # Override destroy to prevent destroying non-revocable keys
242
+ def destroy
243
+ raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
244
+ super
245
+ end
246
+
247
+ def destroy!
248
+ raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
249
+ super
250
+ end
251
+
79
252
  # Basic scope check. Assumes scopes are stored as an array of strings.
80
- # Returns true if the key has no specific scopes (allowing all) or includes the required scope.
253
+ #
254
+ # Behavior depends on whether key_types mode is enabled:
255
+ # - Simple mode (no key_types): blank scopes means "unrestricted" (all scopes allowed).
256
+ # This preserves backwards compatibility for apps that don't use scopes at all.
257
+ # - Key types mode: blank scopes means "no permissions". When you've configured
258
+ # key types with permission ceilings, an empty scope list should deny access,
259
+ # not silently bypass the entire permission system.
81
260
  def allows_scope?(required_scope)
82
- # Type casting for scopes/metadata happens via the attribute definition in the engine.
83
- # Ensure the attribute is loaded/defined before using it.
84
- # Check if the attribute method exists before calling .blank? or .include?
85
- return true unless respond_to?(:scopes) # Guard clause if loaded before attribute definition
86
- scopes.blank? || scopes.include?(required_scope.to_s)
261
+ return false unless respond_to?(:scopes)
262
+ return false unless required_scope.present?
263
+ return false unless scopes.is_a?(Array)
264
+
265
+ if scopes.blank?
266
+ return !scope_policy_enabled?
267
+ end
268
+
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)
275
+ end
276
+
277
+ # Alias for scopes - provides a more user-friendly API that matches
278
+ # the configuration DSL where key types use `permissions` for scope ceiling.
279
+ # Note: We use a method instead of alias_method because `scopes` is defined
280
+ # dynamically via the `attribute` API in the engine initializer.
281
+ # @return [Array<String>] The permissions (scopes) assigned to this key
282
+ def permissions
283
+ scopes
284
+ end
285
+
286
+ # Check if this key has a specific permission (alias for allows_scope?)
287
+ # @param required_permission [String, Symbol] The permission to check
288
+ # @return [Boolean] true if the key has this permission or has no restrictions
289
+ def allows_permission?(required_permission)
290
+ allows_scope?(required_permission)
87
291
  end
88
292
 
89
293
  # Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
@@ -108,25 +312,49 @@ module ApiKeys
108
312
  def set_defaults
109
313
  # NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
110
314
 
111
- # Determine the prefix: owner-specific setting > global config
112
- # Note: `owner` might not be set yet if called outside normal AR flow.
113
- owner_prefix_config = nil
114
- if owner.present? && owner.class.respond_to?(:api_keys_settings)
115
- owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
116
- end
315
+ # If key_types feature is enabled (non-empty key_types config), use type+env prefix
316
+ if key_types_feature_enabled? && key_type.present?
317
+ self.prefix ||= build_typed_prefix
318
+ else
319
+ # Legacy behavior: use owner-specific or global config prefix
320
+ owner_prefix_config = nil
321
+ if owner.present? && owner.class.respond_to?(:api_keys_settings)
322
+ owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
323
+ end
117
324
 
118
- # Use owner setting if present, otherwise fall back to global config
119
- prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
325
+ # Use owner setting if present, otherwise fall back to global config
326
+ prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
120
327
 
121
- # Evaluate the prefix config (it might be a Proc)
122
- # Ensure `self.prefix` is only set if it's not already present.
123
- self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
328
+ # Evaluate the prefix config (it might be a Proc)
329
+ self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
330
+ end
124
331
 
125
332
  # Removed default scopes logic here. It's correctly handled in the
126
333
  # HasApiKeys#create_api_key! helper method, which is the intended
127
334
  # way to create keys with proper default scope application.
128
335
  end
129
336
 
337
+ # Build prefix from key_type and environment configuration
338
+ # e.g., publishable + test → "pk_test_"
339
+ def build_typed_prefix
340
+ type_config = key_type_config
341
+ env_config = environment_config
342
+
343
+ type_prefix = type_config&.dig(:prefix) || key_type.to_s[0..1]
344
+ env_segment = env_config&.dig(:prefix_segment)
345
+
346
+ if env_segment.present?
347
+ "#{type_prefix}_#{env_segment}_"
348
+ else
349
+ "#{type_prefix}_"
350
+ end
351
+ end
352
+
353
+ # Check if key types feature is enabled
354
+ def key_types_feature_enabled?
355
+ ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
356
+ end
357
+
130
358
  # Generates the secure token, hashes it, and sets relevant attributes.
131
359
  # Called before validation on create.
132
360
  def generate_token_and_digest
@@ -142,7 +370,8 @@ module ApiKeys
142
370
 
143
371
  # Safety check: Ensure generated token starts with the expected prefix
144
372
  unless @token.start_with?(self.prefix)
145
- 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."
146
375
  end
147
376
 
148
377
  # Use the configured digestor
@@ -160,10 +389,175 @@ module ApiKeys
160
389
  if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
161
390
  self.expires_at = ApiKeys.configuration.expire_after.from_now
162
391
  end
392
+
393
+ # Store plaintext token in metadata for public, non-revocable keys.
394
+ # This allows users to view the token again in the dashboard.
395
+ # SECURITY: Only do this for keys explicitly configured as public: true
396
+ # AND revocable: false (e.g., publishable keys for distributed apps).
397
+ if public_key_type?
398
+ self.metadata = (self.metadata || {}).merge("token" => @token)
399
+ end
163
400
  end
164
401
 
165
402
  # == Validation Helpers ==
166
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
+
167
561
  def owner_present_and_configured?
168
562
  owner.present? && owner_configured?
169
563
  end
@@ -205,5 +599,42 @@ module ApiKeys
205
599
  errors.add(:expires_at, "can't be in the past") if expires_at.present? && expires_at < Time.current
206
600
  end
207
601
 
602
+ # Non-revocable keys cannot have expiration dates.
603
+ # If a key expires but cannot be revoked/deleted, the user would be stuck
604
+ # with a useless expired key they can't remove.
605
+ def non_revocable_keys_cannot_expire
606
+ return unless key_type.present? && expires_at.present?
607
+
608
+ config = key_type_config
609
+ return unless config # No config = allow (legacy behavior)
610
+
611
+ # If this key type is non-revocable, prevent setting expiration
612
+ if config[:revocable] == false
613
+ errors.add(:expires_at, "cannot be set on non-revocable keys (#{key_type} keys cannot be revoked or deleted)")
614
+ end
615
+ end
616
+
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.
619
+ def within_key_type_limit
620
+ return unless key_types_feature_enabled?
621
+
622
+ config = key_type_config
623
+ return unless config # No config = no limit
624
+
625
+ limit = config[:limit]
626
+ return unless limit # nil limit = unlimited
627
+
628
+ existing_count = owner.api_keys
629
+ .active
630
+ .where(key_type: key_type.to_s)
631
+ .where(environment: environment.to_s)
632
+ .count
633
+
634
+ if existing_count >= limit
635
+ errors.add(:base, "Maximum number of #{key_type} keys (#{limit}) reached for #{environment} environment")
636
+ end
637
+ end
638
+
208
639
  end
209
640
  end