api_keys 0.5.0 → 0.6.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +158 -9
  4. data/SECURITY.md +22 -2
  5. data/app/controllers/api_keys/keys_controller.rb +136 -14
  6. data/app/views/api_keys/keys/_form.html.erb +85 -17
  7. data/app/views/api_keys/keys/_key_row.html.erb +4 -2
  8. data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
  9. data/app/views/api_keys/keys/_restriction_fields.html.erb +4 -2
  10. data/app/views/api_keys/keys/_show_token.html.erb +4 -7
  11. data/app/views/api_keys/keys/edit.html.erb +2 -0
  12. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  13. data/app/views/layouts/api_keys/application.html.erb +6 -0
  14. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  15. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  16. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  17. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  18. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  19. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  20. data/lib/api_keys/authentication.rb +123 -7
  21. data/lib/api_keys/configuration.rb +37 -6
  22. data/lib/api_keys/form_builder_extensions.rb +42 -3
  23. data/lib/api_keys/helpers/token_session.rb +257 -8
  24. data/lib/api_keys/helpers/view_helpers.rb +33 -0
  25. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  26. data/lib/api_keys/models/api_key.rb +293 -7
  27. data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
  28. data/lib/api_keys/restrictions.rb +75 -24
  29. data/lib/api_keys/services/authenticator.rb +24 -2
  30. data/lib/api_keys/version.rb +1 -1
  31. data/lib/api_keys.rb +7 -0
  32. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  33. data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
  34. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  35. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +12 -0
  36. data/lib/generators/api_keys/templates/initializer.rb +32 -7
  37. metadata +10 -1
@@ -15,6 +15,21 @@ module ApiKeys
15
15
  MAX_RESTRICTION_ENTRIES = 100
16
16
  MAX_RESTRICTION_ENTRY_BYTESIZE = 255
17
17
  RESTRICTIONS_COLUMN = "restrictions"
18
+ # Refused-but-identified traffic (0.6.0). Kept apart from last_used_at and
19
+ # requests_count, which only ever describe successful authentications.
20
+ REFUSAL_STATS_COLUMNS = %w[last_refused_at refusals_count last_refusal_code].freeze
21
+ # Durable one-time token handoff (0.6.0): stamped by the first retrieval
22
+ # of the key's token through ApiKeys::TokenSession, and never cleared.
23
+ TOKEN_SHOWN_AT_COLUMN = "token_shown_at"
24
+ # Every refusal that happens after the presented token was verified, so the
25
+ # key is known: lifecycle, type/environment configuration, isolation,
26
+ # request restrictions, and scopes. Lookup failures (missing or invalid
27
+ # token, insecure transport) name no key and are never stamped on one.
28
+ REFUSAL_CODES = %w[
29
+ revoked_key expired_key unknown_key_type unknown_environment
30
+ environment_misconfigured environment_mismatch
31
+ origin_not_allowed ip_not_allowed restriction_misconfigured missing_scope
32
+ ].freeze
18
33
  # Deliberately excludes `restrictions`: owners must be able to tighten a
19
34
  # request policy even when the key itself is non-revocable.
20
35
  IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
@@ -32,6 +47,11 @@ module ApiKeys
32
47
  # == Attributes & Serialization ==
33
48
  # Expose the plaintext token only immediately after creation
34
49
  attr_reader :token
50
+
51
+ # Written only by the atomic claim in `claim_token_handoff!`. Ordinary
52
+ # saves can never set or clear it, so no update path (a form, mass
53
+ # assignment, a stray `update`) can reopen a handoff that was claimed.
54
+ attr_readonly :token_shown_at
35
55
  attr_accessor :expires_at_preset
36
56
 
37
57
  # JSON attributes (:scopes, :metadata) are defined in the engine initializer
@@ -64,16 +84,18 @@ module ApiKeys
64
84
  end
65
85
 
66
86
  # Accepts a Restrictions instance, a hash of lists, or nil. Hashes are
67
- # normalized into the storage shape; anything else is stored untouched so
68
- # the validation, rather than a silent coercion, is what reports it.
87
+ # normalized into the storage shape; a kind given as `nil` or blank text
88
+ # is cleared, as the list writers do. Anything malformed is stored
89
+ # untouched so the validation, rather than a silent coercion, reports it.
69
90
  def restrictions=(value)
70
91
  ensure_restrictions_column!
71
92
 
72
93
  normalized = if value.nil?
73
94
  {}
74
95
  elsif value.is_a?(Hash)
75
- wrapped = ApiKeys::Restrictions.wrap(value)
76
- wrapped.malformed? ? value : wrapped.to_h
96
+ input = ApiKeys::Restrictions.normalize_input(value)
97
+ wrapped = ApiKeys::Restrictions.wrap(input)
98
+ wrapped.malformed? ? input : wrapped.to_h
77
99
  elsif value.is_a?(ApiKeys::Restrictions)
78
100
  value.malformed? ? { "__malformed__" => true } : value.to_h
79
101
  else
@@ -212,9 +234,30 @@ module ApiKeys
212
234
 
213
235
  # == Instance Methods ==
214
236
 
237
+ # Revocation is the emergency control, so it must not hinge on whether the
238
+ # rest of the row still satisfies today's policy. A key issued before its
239
+ # type's permission or restriction ceiling was narrowed keeps
240
+ # authenticating within the narrower ceiling; refusing to revoke it would
241
+ # leave a credential nobody can switch off.
242
+ #
243
+ # The revocability guard always applies. When the revocation is the only
244
+ # pending change, the row is saved without validations: only `revoked_at`
245
+ # (and `updated_at`) are written, and save callbacks run as before. Other
246
+ # unsaved edits on the same instance are never written unvalidated: in
247
+ # that case the whole save is validated, exactly as before 0.5.1.
248
+ #
249
+ # The authenticator's token cache holds only a key ID and reloads the row
250
+ # on every hit, so the next request is refused as revoked with no cache
251
+ # invalidation needed.
215
252
  def revoke!
216
253
  raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
217
- update!(revoked_at: Time.current)
254
+
255
+ self.revoked_at = Time.current
256
+ if persisted? && (changes_to_save.keys - %w[revoked_at]).empty?
257
+ save!(validate: false)
258
+ else
259
+ save!
260
+ end
218
261
  end
219
262
 
220
263
  def revoked?
@@ -333,11 +376,23 @@ module ApiKeys
333
376
  # - Key types mode: blank scopes means "no permissions". When you've configured
334
377
  # key types with permission ceilings, an empty scope list should deny access,
335
378
  # not silently bypass the entire permission system.
379
+ #
380
+ # Key types configured with `scopes: :ceiling` skip the stored list: the
381
+ # type's live permission ceiling is the contract, so a widened ceiling
382
+ # reaches existing keys and a narrowed one takes effect on the next request.
336
383
  def allows_scope?(required_scope)
337
384
  return false unless respond_to?(:scopes)
338
385
  return false unless required_scope.present?
339
386
  return false unless scopes.is_a?(Array)
340
387
 
388
+ if scopes_follow_ceiling?
389
+ # Stored scopes are metadata here, but a damaged list still means
390
+ # something wrote around validation: refuse rather than guess.
391
+ return false unless scopes.all? { |scope| valid_scope_value?(scope) }
392
+
393
+ return followed_ceiling.include?(required_scope.to_s)
394
+ end
395
+
341
396
  if scopes.blank?
342
397
  return !scope_policy_enabled?
343
398
  end
@@ -350,6 +405,78 @@ module ApiKeys
350
405
  ceiling == :all || ceiling.include?(required)
351
406
  end
352
407
 
408
+ # Whether this key's scope list is enforced, so an empty list grants no
409
+ # permissions. True for typed keys, whenever `key_types` or the global
410
+ # `default_scopes` are configured, and when the owner model's
411
+ # `has_api_keys` declares `default_scopes`. The last case is read through
412
+ # `owner`, so a key that does not know its owner yet (a bare `ApiKey.new`)
413
+ # cannot see it: build keys from their owner (`owner.api_keys.build`).
414
+ # False otherwise, where an empty list keeps its legacy meaning:
415
+ # unrestricted. Forms use it to decide whether "every box cleared" can be
416
+ # submitted as an empty list without widening the key.
417
+ #
418
+ # @return [Boolean]
419
+ def scopes_enforced?
420
+ scope_policy_enabled?
421
+ end
422
+
423
+ # Whether a scope is inside this key's current permission ceiling: its
424
+ # type's `permissions:`, or the configured scopes for an untyped key.
425
+ # A held scope outside it (left behind when a ceiling was narrowed) is
426
+ # already denied by `allows_scope?` and fails the ceiling validation on
427
+ # the next save.
428
+ #
429
+ # @param scope [String, Symbol]
430
+ # @return [Boolean]
431
+ def scope_within_ceiling?(scope)
432
+ ceiling = permission_ceiling
433
+ ceiling == :all || ceiling.include?(scope.to_s)
434
+ end
435
+
436
+ # The scopes to save after a form that offered only some scopes as
437
+ # checkboxes was submitted: the rule the dashboard's edit form uses, for
438
+ # host forms built with `api_key_scopes_checkboxes`.
439
+ #
440
+ # - Offered scopes are set from the boxes: checked ones are granted,
441
+ # unchecked ones removed.
442
+ # - Every other scope the key holds is kept, in its current order.
443
+ # - A checked value that was never offered is ignored, so a crafted
444
+ # request cannot grant it.
445
+ # - A held scope outside the key's current ceiling is dropped, offered or
446
+ # not. Authentication already denies it, and keeping it would make every
447
+ # save of the key fail the ceiling validation. A newly checked scope
448
+ # outside the ceiling is kept, so the validation reports it.
449
+ # - `checked: nil` means the form submitted no scopes at all (no boxes and
450
+ # no sentinel): nothing is granted or removed except held scopes outside
451
+ # the ceiling.
452
+ # - A `scopes: :ceiling` key returns its stored scopes unchanged: they are
453
+ # metadata there, and the type's live ceiling authorizes the key.
454
+ #
455
+ # It only computes the list; assign it (`key.update(scopes: ...)`) to save.
456
+ #
457
+ # @param offered [Array<String, Symbol>] The scopes the form rendered as boxes.
458
+ # @param checked [Array<String, Symbol>, nil] The submitted values; blank
459
+ # entries (the sentinel) are ignored.
460
+ # @return [Array<String>]
461
+ # @raise [ArgumentError] when +offered+ or +checked+ is not an Array (or nil for +checked+).
462
+ def scopes_after_form_edit(offered:, checked:)
463
+ raise ArgumentError, "offered must be an Array of scope names" unless offered.is_a?(Array)
464
+ unless checked.nil? || checked.is_a?(Array)
465
+ raise ArgumentError, "checked must be an Array of scope names, or nil when no scopes were submitted"
466
+ end
467
+ return scopes.deep_dup if scopes_follow_ceiling?
468
+
469
+ offered = checked.nil? ? [] : offered.map(&:to_s)
470
+ checked = Array(checked).reject { |scope| scope.respond_to?(:blank?) && scope.blank? }.map(&:to_s)
471
+ held = Array(scopes)
472
+
473
+ kept = held.select do |scope|
474
+ scope_within_ceiling?(scope) && (!offered.include?(scope) || checked.include?(scope))
475
+ end
476
+ added = offered.select { |scope| checked.include?(scope) && !held.include?(scope) }
477
+ kept + added
478
+ end
479
+
353
480
  # Alias for scopes - provides a more user-friendly API that matches
354
481
  # the configuration DSL where key types use `permissions` for scope ceiling.
355
482
  # Note: We use a method instead of alias_method because `scopes` is defined
@@ -366,6 +493,52 @@ module ApiKeys
366
493
  allows_scope?(required_permission)
367
494
  end
368
495
 
496
+ # Whether scope checks for this key read its type's live permission ceiling
497
+ # instead of the scopes stored at creation (`scopes: :ceiling` key types).
498
+ # Untyped keys, keys whose type is no longer configured, and every other
499
+ # type answer false and keep the stored-scope behavior.
500
+ # @return [Boolean]
501
+ def scopes_follow_ceiling?
502
+ return false if key_type.blank?
503
+
504
+ self.class.scopes_follow_ceiling_for?(key_type_config)
505
+ end
506
+
507
+ # == Refused-but-identified traffic ==
508
+ # Stamped asynchronously when a request presents this key's real token and a
509
+ # policy refuses it. Installations that have not run
510
+ # `rails generate api_keys:add_key_activity` read nil / 0 instead of raising.
511
+
512
+ # @return [Time, nil] When the most recently recorded refusal happened.
513
+ def last_refused_at
514
+ self.class.refusal_stats_columns? ? self[:last_refused_at] : nil
515
+ end
516
+
517
+ # @return [Integer] Refusals counted while `track_requests_count` is on.
518
+ def refusals_count
519
+ self.class.refusal_stats_columns? ? self[:refusals_count].to_i : 0
520
+ end
521
+
522
+ # @return [String, nil] The error code of the most recently recorded refusal.
523
+ def last_refusal_code
524
+ self.class.refusal_stats_columns? ? self[:last_refusal_code] : nil
525
+ end
526
+
527
+ # == One-time token handoff ==
528
+
529
+ # When this key's token was released through the one-time handoff
530
+ # (ApiKeys::TokenSession). Nil when it never was, and on installations that
531
+ # have not run `rails generate api_keys:add_key_activity` yet.
532
+ # @return [Time, nil]
533
+ def token_shown_at
534
+ self.class.token_shown_at_column? ? self[:token_shown_at] : nil
535
+ end
536
+
537
+ # @return [Boolean] Whether the one-time handoff already released this key's token.
538
+ def token_shown?
539
+ !token_shown_at.nil?
540
+ end
541
+
369
542
  # Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
370
543
  # Requires the plaintext token to be available (only right after creation).
371
544
  def masked_token
@@ -391,6 +564,105 @@ module ApiKeys
391
564
  false
392
565
  end
393
566
 
567
+ # Whether the refused-traffic columns exist. Installations that predate
568
+ # v0.6.0 keep authenticating untouched until they run the generator.
569
+ # @return [Boolean]
570
+ def self.refusal_stats_columns?
571
+ (REFUSAL_STATS_COLUMNS - column_names).empty?
572
+ rescue StandardError
573
+ false
574
+ end
575
+
576
+ # Whether the `token_shown_at` column exists. Without it the one-time
577
+ # handoff falls back to the cache marker alone, as in 0.5.1.
578
+ # @return [Boolean]
579
+ def self.token_shown_at_column?
580
+ column_names.include?(TOKEN_SHOWN_AT_COLUMN)
581
+ rescue StandardError
582
+ false
583
+ end
584
+
585
+ # Durably claims the one-time token handoff of the key with +id+ in one
586
+ # conditional statement:
587
+ #
588
+ # UPDATE api_keys SET token_shown_at = now WHERE id = ? AND token_shown_at IS NULL
589
+ #
590
+ # The database applies it atomically, so exactly one caller ever gets true
591
+ # for a key: across processes, across copies of a session, and after the
592
+ # cache is flushed. Zero rows changed means the token was already shown or
593
+ # the key no longer exists, and answers false (fail closed).
594
+ #
595
+ # Answers nil when the column does not exist yet (nothing can be recorded
596
+ # durably). Database errors are raised, not rescued: the caller withholds
597
+ # the token.
598
+ #
599
+ # @param id [Integer, String] The key's primary key.
600
+ # @return [Boolean, nil]
601
+ def self.claim_token_handoff!(id)
602
+ return nil unless column_names.include?(TOKEN_SHOWN_AT_COLUMN)
603
+ return false if id.nil?
604
+
605
+ where(primary_key => id, TOKEN_SHOWN_AT_COLUMN => nil)
606
+ .update_all(TOKEN_SHOWN_AT_COLUMN => Time.current) == 1
607
+ end
608
+
609
+ # Read-only companion of `claim_token_handoff!`: whether a claim for +id+
610
+ # could still succeed. Nil without the column. Raises on database errors.
611
+ #
612
+ # @param id [Integer, String] The key's primary key.
613
+ # @return [Boolean, nil]
614
+ def self.token_handoff_claimable?(id)
615
+ return nil unless column_names.include?(TOKEN_SHOWN_AT_COLUMN)
616
+ return false if id.nil?
617
+
618
+ where(primary_key => id, TOKEN_SHOWN_AT_COLUMN => nil).exists?
619
+ end
620
+
621
+ # Single source of truth for `scopes: :ceiling`. The finite-ceiling check
622
+ # repeats the configuration validation on purpose: a ceiling of `:all` must
623
+ # never become the contract, however the configuration was produced.
624
+ def self.scopes_follow_ceiling_for?(type_config)
625
+ return false unless type_config.is_a?(Hash) && type_config[:scopes].to_s == "ceiling"
626
+
627
+ permissions = type_config[:permissions]
628
+ permissions.is_a?(Array) && permissions.any?
629
+ end
630
+
631
+ # Add-only merge of a key type's current permission ceiling into the stored
632
+ # scopes of every non-revoked key of that type: the backfill for types that
633
+ # keep stored scopes after their ceiling was widened. It only ever adds
634
+ # permissions the type already allows, and it re-adds any a user removed
635
+ # by hand, so run it only for types whose users do not curate scopes.
636
+ #
637
+ # @param key_type [Symbol, String] A configured type with a finite ceiling.
638
+ # @return [Integer] How many keys gained scopes.
639
+ def self.reconcile_scopes!(key_type:)
640
+ type_name, type_config = ApiKeys.configuration.key_types.find { |name, _settings| name.to_s == key_type.to_s }
641
+ raise ArgumentError, "Unknown key type '#{key_type}'" unless type_config
642
+
643
+ ceiling = type_config[:permissions]
644
+ unless ceiling.is_a?(Array) && ceiling.any?
645
+ raise ArgumentError, "Key type '#{key_type}' has no finite permission ceiling to reconcile"
646
+ end
647
+
648
+ ceiling = ceiling.map(&:to_s)
649
+ where(key_type: type_name.to_s, revoked_at: nil).find_each.count do |api_key|
650
+ api_key.with_lock do
651
+ # with_lock reloads the row: a key revoked (or retyped) after it was
652
+ # selected is skipped, so a revoked key never gains scopes.
653
+ next false if api_key.revoked_at.present? || api_key.key_type.to_s != type_name.to_s
654
+
655
+ stored = api_key.scopes
656
+ next false unless stored.is_a?(Array) && stored.all? { |scope| api_key.send(:valid_scope_value?, scope) }
657
+
658
+ merged = stored | ceiling
659
+ next false if merged == stored || merged.length > MAX_SCOPES
660
+
661
+ api_key.update_columns(scopes: merged, updated_at: Time.current)
662
+ end
663
+ end
664
+ end
665
+
394
666
  # Single source of truth for a key type's request-restriction ceiling.
395
667
  # Omitting the setting allows every supported restriction kind.
396
668
  def self.restriction_kinds_for(type_config)
@@ -590,8 +862,6 @@ module ApiKeys
590
862
  return
591
863
  end
592
864
 
593
- errors.add(:restrictions, "must contain valid restriction data") if restrictions.malformed? && raw.keys.empty?
594
-
595
865
  unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES
596
866
  if unknown_kinds.any?
597
867
  errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}")
@@ -599,6 +869,12 @@ module ApiKeys
599
869
 
600
870
  validate_restriction_list(:origins) { |entry| ApiKeys::Restrictions.valid_origin_entry?(entry) }
601
871
  validate_restriction_list(:ips) { |entry| ApiKeys::Restrictions.valid_ip_entry?(entry) }
872
+
873
+ # Whatever made the policy malformed, it never saves: a policy that
874
+ # authentication would refuse must not be written in the first place.
875
+ if restrictions.malformed? && errors[:restrictions].empty?
876
+ errors.add(:restrictions, "must contain valid restriction data")
877
+ end
602
878
  end
603
879
 
604
880
  def validate_restriction_list(kind)
@@ -651,6 +927,11 @@ module ApiKeys
651
927
 
652
928
  def scopes_respect_permission_ceiling
653
929
  return unless scopes.is_a?(Array)
930
+ # On a `scopes: :ceiling` key the stored list authorizes nothing. Narrowing
931
+ # the ceiling must not leave every existing key of the type unsaveable
932
+ # (which would also block tightening its request restrictions), so only
933
+ # writes to the scopes themselves are held to the current ceiling.
934
+ return if persisted? && scopes_follow_ceiling? && !will_save_change_to_attribute?(:scopes)
654
935
 
655
936
  ceiling = permission_ceiling
656
937
  return if ceiling == :all
@@ -699,6 +980,11 @@ module ApiKeys
699
980
  false
700
981
  end
701
982
 
983
+ # The live ceiling a `scopes: :ceiling` key is authorized for.
984
+ def followed_ceiling
985
+ Array(key_type_config&.[](:permissions)).map(&:to_s)
986
+ end
987
+
702
988
  def permission_ceiling
703
989
  if key_type.present?
704
990
  config = key_type_config
@@ -386,10 +386,18 @@ module ApiKeys
386
386
  raise ArgumentError, "restrictions must be a Hash or ApiKeys::Restrictions"
387
387
  end
388
388
 
389
- attributes = ApiKeys::Restrictions.wrap(restrictions).to_h
389
+ # `nil` or blank text for a kind clears it, as the list writers do.
390
+ # A malformed policy goes to the model as it came, so validation
391
+ # rejects it. Normalizing it first could reduce it to `{}`, which
392
+ # would create the key unrestricted.
393
+ input = ApiKeys::Restrictions.normalize_input(restrictions)
394
+ wrapped = ApiKeys::Restrictions.wrap(input)
395
+ return input if wrapped.malformed?
396
+
397
+ attributes = wrapped.to_h
390
398
  attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil?
391
399
  attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil?
392
- ApiKeys::Restrictions.wrap(attributes).to_h
400
+ attributes
393
401
  end
394
402
 
395
403
  # True when any configured key type declares a restriction ceiling.
@@ -55,9 +55,11 @@ module ApiKeys
55
55
  known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) }
56
56
  known = known.to_h { |key, entries| [key.to_s, entries] }
57
57
 
58
+ # Only an absent kind means "no list". A kind that is present must
59
+ # hold a list; `"ips" => nil` is as malformed as `"ips" => false`.
58
60
  new(
59
- origins: coerce_list(known["origins"]),
60
- ips: coerce_list(known["ips"]),
61
+ origins: known.key?("origins") ? coerce_list(known["origins"]) : [],
62
+ ips: known.key?("ips") ? coerce_list(known["ips"]) : [],
61
63
  extras: extras.to_h
62
64
  )
63
65
  rescue StandardError
@@ -67,6 +69,27 @@ module ApiKeys
67
69
  new(origins: [], ips: [], extras: {}, malformed: true)
68
70
  end
69
71
 
72
+ # Reads a restrictions hash a caller passes in (`ApiKey#restrictions=`
73
+ # and `create_api_key!(restrictions:)`). A kind given as `nil` or blank
74
+ # text is cleared, exactly as the `allowed_origins=` / `allowed_ips=`
75
+ # writers clear it. Everything else passes through untouched, so
76
+ # `false`, wrong types, and malformed list members still fail
77
+ # validation. Stored rows never go through here: `wrap` reads them
78
+ # strictly, and a stored `null` refuses authentication.
79
+ #
80
+ # @param value [Hash, Object] Caller input; non-hashes are returned as is.
81
+ # @return [Hash, Object]
82
+ def normalize_input(value)
83
+ return value unless value.is_a?(Hash)
84
+
85
+ value.to_h do |kind, entries|
86
+ cleared = KIND_NAMES.include?(kind.to_s) && blank_input?(entries)
87
+ [kind, cleared ? [] : entries]
88
+ end
89
+ rescue StandardError
90
+ value
91
+ end
92
+
70
93
  # The shared empty instance: no origins, no IPs, no restrictions at all.
71
94
  # @return [ApiKeys::Restrictions]
72
95
  def none
@@ -128,6 +151,11 @@ module ApiKeys
128
151
  end
129
152
 
130
153
  # Splits raw input into candidate entries without interpreting them.
154
+ #
155
+ # `nil` and blank text are the raw-input spellings of an empty list (a
156
+ # cleared dashboard field). Every other non-string value, including
157
+ # `false` and a `nil` inside an array, is kept as an entry so that
158
+ # validation rejects it: malformed input must never read as "unrestricted".
131
159
  # @api private
132
160
  def tokenize(value)
133
161
  entries = case value
@@ -137,11 +165,14 @@ module ApiKeys
137
165
  else [value]
138
166
  end
139
167
 
140
- entries.filter_map do |entry|
141
- next entry unless entry.is_a?(String)
168
+ entries.each_with_object([]) do |entry, tokens|
169
+ unless entry.is_a?(String)
170
+ tokens << entry
171
+ next
172
+ end
142
173
 
143
174
  trimmed = entry.strip
144
- trimmed unless trimmed.empty?
175
+ tokens << trimmed unless trimmed.empty?
145
176
  end
146
177
  end
147
178
 
@@ -190,26 +221,46 @@ module ApiKeys
190
221
  nil
191
222
  end
192
223
 
193
- # Coerces one stored list into an array of entries, preserving anything
194
- # that is not a string so validations can report it instead of the value
195
- # disappearing silently.
224
+ # Coerces one present list (storage shape) into an array of entries.
225
+ #
226
+ # Nothing is ever dropped: `false`, `nil`, numbers, hashes, and blank
227
+ # strings inside an array stay as entries, and a scalar that is not a
228
+ # string (`false`, `nil`, `42`) becomes a single entry, so validation
229
+ # rejects them at write time and authentication refuses them at read
230
+ # time. The only spelling of an intentionally empty list is `[]` (or
231
+ # leaving the kind out). A string is accepted as a raw list
232
+ # ("a.com, b.com"), but it must contain at least one entry.
196
233
  # @api private
197
234
  def coerce_list(value)
198
- entries = case value
199
- when nil then []
200
- when String then value.split(ENTRY_SEPARATOR)
201
- when Array then value
202
- else [value]
203
- end
235
+ case value
236
+ when Array
237
+ value.map { |entry| coerce_entry(entry) }
238
+ when String
239
+ entries = value.split(ENTRY_SEPARATOR).reject(&:empty?).map { |entry| coerce_entry(entry) }
240
+ entries.empty? ? [value] : entries
241
+ else
242
+ [value]
243
+ end
244
+ rescue ArgumentError
245
+ # A string with invalid encoding cannot be split; keep it as the
246
+ # invalid entry it is.
247
+ [value]
248
+ end
204
249
 
205
- entries.filter_map do |entry|
206
- next entry unless entry.is_a?(String)
250
+ # @api private
251
+ def blank_input?(entries)
252
+ entries.nil? || (entries.is_a?(String) && entries.strip.empty?)
253
+ rescue ArgumentError
254
+ false
255
+ end
207
256
 
208
- trimmed = entry.strip.downcase
209
- trimmed unless trimmed.empty?
210
- rescue ArgumentError
211
- entry
212
- end
257
+ # @api private
258
+ def coerce_entry(entry)
259
+ return entry unless entry.is_a?(String)
260
+
261
+ entry.strip.downcase
262
+ rescue ArgumentError
263
+ entry
213
264
  end
214
265
 
215
266
  # Whether a stored origin entry is shaped like a host or `*.host`.
@@ -278,7 +329,7 @@ module ApiKeys
278
329
 
279
330
  # @return [Array<Symbol>] The restriction kinds actually in use.
280
331
  def kinds
281
- KINDS.select { |kind| public_send(kind).any? }
332
+ KINDS.reject { |kind| public_send(kind).empty? }
282
333
  end
283
334
 
284
335
  # The storage shape: known lists that have entries, plus any unrecognized
@@ -286,8 +337,8 @@ module ApiKeys
286
337
  # @return [Hash]
287
338
  def to_h
288
339
  hash = {}
289
- hash["origins"] = deep_copy(origins) if origins.any?
290
- hash["ips"] = deep_copy(ips) if ips.any?
340
+ hash["origins"] = deep_copy(origins) unless origins.empty?
341
+ hash["ips"] = deep_copy(ips) unless ips.empty?
291
342
  hash.merge(deep_copy(extras))
292
343
  end
293
344
 
@@ -15,6 +15,12 @@ module ApiKeys
15
15
  extend ApiKeys::Logging
16
16
 
17
17
  MAX_TOKEN_BYTESIZE = 512
18
+ # The longest authentication header that can still carry a valid token:
19
+ # the token plus the "Bearer" scheme and generous separator whitespace.
20
+ MAX_CREDENTIAL_HEADER_BYTESIZE = MAX_TOKEN_BYTESIZE + 64
21
+ # Stands in for a header refused before parsing. It is not a String, so
22
+ # it can never match a key and always yields :invalid_token.
23
+ MALFORMED_CREDENTIAL = Object.new.freeze
18
24
  MAX_BCRYPT_CANDIDATES = 32
19
25
  MAX_KNOWN_PREFIXES = 1_024
20
26
  TOKEN_CACHE_NAMESPACE = "api_keys:v2:token"
@@ -125,6 +131,15 @@ module ApiKeys
125
131
  unless header_value.nil?
126
132
  return header_value unless header_value.is_a?(String)
127
133
 
134
+ # Check the raw header before parsing it: a regular expression over
135
+ # invalid UTF-8 raises, and an oversized value should never be
136
+ # scanned. A refused header is not a String, so valid_token?
137
+ # answers with the ordinary invalid_token failure.
138
+ unless acceptable_raw_credential?(header_value)
139
+ log_debug "[ApiKeys Auth] Rejected a malformed authentication header before parsing it."
140
+ return MALFORMED_CREDENTIAL
141
+ end
142
+
128
143
  # Handle "Bearer <token>" scheme
129
144
  match = header_value.match(/\ABearer[ \t]+(.+)\z/i)
130
145
  if match
@@ -342,6 +357,12 @@ module ApiKeys
342
357
  end.uniq
343
358
  end
344
359
 
360
+ # Encoding and size of the raw header, checked without any regular
361
+ # expression so that hostile bytes cannot raise or cost a scan.
362
+ def self.acceptable_raw_credential?(value)
363
+ value.bytesize <= MAX_CREDENTIAL_HEADER_BYTESIZE && value.valid_encoding?
364
+ end
365
+
345
366
  def self.valid_token?(token)
346
367
  return false unless token.is_a?(String)
347
368
  return false if token.empty? || token.bytesize > MAX_TOKEN_BYTESIZE || !token.valid_encoding?
@@ -464,7 +485,7 @@ module ApiKeys
464
485
  )
465
486
  end
466
487
 
467
- if restrictions.origins.any?
488
+ unless restrictions.origins.empty?
468
489
  origin_host = ApiKeys::Restrictions.extract_origin_host(request)
469
490
  unless restrictions.origin_allowed?(origin_host)
470
491
  log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed."
@@ -476,7 +497,7 @@ module ApiKeys
476
497
  end
477
498
  end
478
499
 
479
- if restrictions.ips.any?
500
+ unless restrictions.ips.empty?
480
501
  unless restrictions.ip_allowed?(resolve_client_ip(request, config))
481
502
  log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed."
482
503
  return Result.failure(
@@ -510,6 +531,7 @@ module ApiKeys
510
531
  :safe_cache_read, :safe_cache_write,
511
532
  :normalized_cache_ttl, :cache_identifier?,
512
533
  :safe_find_by_id, :sanitize_prefixes, :valid_token?,
534
+ :acceptable_raw_credential?,
513
535
  :production_environment?, :secure_request?,
514
536
  :check_key_type_configuration, :check_environment_configuration,
515
537
  :check_environment_isolation, :check_request_restrictions,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/api_keys.rb CHANGED
@@ -42,6 +42,13 @@ module ApiKeys
42
42
  def reset_configuration!
43
43
  @configuration = ::ApiKeys::Configuration.new
44
44
  end
45
+
46
+ # Add-only backfill after widening a key type's `permissions:` ceiling, for
47
+ # types that keep stored scopes. See ApiKeys::ApiKey.reconcile_scopes!.
48
+ # @return [Integer] How many keys gained scopes.
49
+ def reconcile_scopes!(key_type:)
50
+ ::ApiKeys::ApiKey.reconcile_scopes!(key_type: key_type)
51
+ end
45
52
  end
46
53
  end
47
54