api_keys 0.5.1 → 0.7.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +402 -13
  4. data/SECURITY.md +7 -6
  5. data/app/controllers/api_keys/keys_controller.rb +73 -46
  6. data/app/views/api_keys/keys/_form.html.erb +56 -10
  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/_show_token.html.erb +4 -7
  10. data/app/views/api_keys/keys/edit.html.erb +2 -0
  11. data/app/views/layouts/api_keys/application.html.erb +6 -0
  12. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  13. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  14. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  15. data/lib/api_keys/authentication.rb +174 -7
  16. data/lib/api_keys/configuration.rb +82 -0
  17. data/lib/api_keys/device_flow.rb +146 -0
  18. data/lib/api_keys/engine.rb +9 -0
  19. data/lib/api_keys/errors.rb +11 -0
  20. data/lib/api_keys/form_builder_extensions.rb +3 -1
  21. data/lib/api_keys/helpers/token_session.rb +183 -23
  22. data/lib/api_keys/helpers/view_helpers.rb +34 -0
  23. data/lib/api_keys/idempotency.rb +155 -0
  24. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  25. data/lib/api_keys/models/api_key.rb +261 -3
  26. data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
  27. data/lib/api_keys/models/device_authorization.rb +333 -0
  28. data/lib/api_keys/models/idempotency_key.rb +184 -0
  29. data/lib/api_keys/version.rb +1 -1
  30. data/lib/api_keys.rb +13 -0
  31. data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
  32. data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
  33. data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
  34. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  35. data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
  36. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  37. data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
  39. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
  40. data/lib/generators/api_keys/templates/initializer.rb +35 -0
  41. metadata +17 -1
@@ -10,15 +10,39 @@ module ApiKeys
10
10
  # The core ActiveRecord model representing an API key.
11
11
  class ApiKey < ActiveRecord::Base
12
12
  MAX_SCOPES = 100
13
+ # A key name: letters, numbers, underscores, hyphens and spaces.
14
+ MAX_NAME_LENGTH = 60
15
+ NAME_FORMAT = /\A[a-zA-Z0-9_ -]+\z/
13
16
  MAX_SCOPE_BYTESIZE = 128
14
17
  MAX_METADATA_BYTESIZE = 16_384
15
18
  MAX_RESTRICTION_ENTRIES = 100
16
19
  MAX_RESTRICTION_ENTRY_BYTESIZE = 255
17
20
  RESTRICTIONS_COLUMN = "restrictions"
21
+ # Refused-but-identified traffic (0.6.0). Kept apart from last_used_at and
22
+ # requests_count, which only ever describe successful authentications.
23
+ REFUSAL_STATS_COLUMNS = %w[last_refused_at refusals_count last_refusal_code].freeze
24
+ # Durable one-time token handoff (0.6.0): stamped by the first retrieval
25
+ # of the key's token through ApiKeys::TokenSession, and never cleared.
26
+ TOKEN_SHOWN_AT_COLUMN = "token_shown_at"
27
+ # Every refusal that happens after the presented token was verified, so the
28
+ # key is known: lifecycle, type/environment configuration, isolation,
29
+ # request restrictions, and scopes. Lookup failures (missing or invalid
30
+ # token, insecure transport) name no key and are never stamped on one.
31
+ REFUSAL_CODES = %w[
32
+ revoked_key expired_key unknown_key_type unknown_environment
33
+ environment_misconfigured environment_mismatch
34
+ origin_not_allowed ip_not_allowed restriction_misconfigured missing_scope
35
+ untrusted_key
36
+ ].freeze
37
+ # The person who created the key (0.7.0). Optional columns.
38
+ CREATED_BY_COLUMNS = %w[created_by_type created_by_id].freeze
18
39
  # Deliberately excludes `restrictions`: owners must be able to tighten a
19
- # request policy even when the key itself is non-revocable.
40
+ # request policy even when the key itself is non-revocable. Includes the
41
+ # person who created the key: `config.trusted_key_check` may trust a key
42
+ # because of that person, so no update can name another one.
20
43
  IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
21
44
  token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
45
+ created_by_type created_by_id
22
46
  ].freeze
23
47
 
24
48
  self.table_name = "api_keys"
@@ -28,10 +52,24 @@ module ApiKeys
28
52
 
29
53
  # == Associations ==
30
54
  belongs_to :owner, polymorphic: true, optional: true
55
+ # The person behind the key, when the host names one at creation:
56
+ # `create_api_key!(created_by: current_user)`. Needs the created_by
57
+ # columns (`rails generate api_keys:add_created_by`).
58
+ belongs_to :created_by, polymorphic: true, optional: true
31
59
 
32
60
  # == Attributes & Serialization ==
33
61
  # Expose the plaintext token only immediately after creation
34
62
  attr_reader :token
63
+
64
+ # Written only by the atomic claim in `claim_token_handoff!`. Ordinary
65
+ # saves can never set or clear it, so no update path (a form, mass
66
+ # assignment, a stray `update`) can reopen a handoff that was claimed.
67
+ attr_readonly :token_shown_at
68
+ # The person behind the key is fixed at creation. `config.trusted_key_check`
69
+ # may trust a key because of that person, so no save (with or without
70
+ # validations) and no update_column can name another one. A backfill of
71
+ # keys made before the columns existed writes them with SQL (update_all).
72
+ attr_readonly :created_by_type, :created_by_id
35
73
  attr_accessor :expires_at_preset
36
74
 
37
75
  # JSON attributes (:scopes, :metadata) are defined in the engine initializer
@@ -118,9 +156,9 @@ module ApiKeys
118
156
  # validates :scopes, presence: true # Default handled by attribute def
119
157
  # validates :metadata, presence: true # Default handled by attribute def
120
158
  validates :name,
121
- length: { maximum: 60 },
159
+ length: { maximum: MAX_NAME_LENGTH },
122
160
  # Allow letters, numbers, underscores, hyphens, and spaces. No leading/trailing spaces.
123
- format: { with: /\A[a-zA-Z0-9_ -]+\z/, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
161
+ format: { with: NAME_FORMAT, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
124
162
  allow_blank: true # Apply length and format only if name is present
125
163
 
126
164
  validates :name, presence: true, if: :name_required? # Only require presence conditionally
@@ -356,11 +394,23 @@ module ApiKeys
356
394
  # - Key types mode: blank scopes means "no permissions". When you've configured
357
395
  # key types with permission ceilings, an empty scope list should deny access,
358
396
  # not silently bypass the entire permission system.
397
+ #
398
+ # Key types configured with `scopes: :ceiling` skip the stored list: the
399
+ # type's live permission ceiling is the contract, so a widened ceiling
400
+ # reaches existing keys and a narrowed one takes effect on the next request.
359
401
  def allows_scope?(required_scope)
360
402
  return false unless respond_to?(:scopes)
361
403
  return false unless required_scope.present?
362
404
  return false unless scopes.is_a?(Array)
363
405
 
406
+ if scopes_follow_ceiling?
407
+ # Stored scopes are metadata here, but a damaged list still means
408
+ # something wrote around validation: refuse rather than guess.
409
+ return false unless scopes.all? { |scope| valid_scope_value?(scope) }
410
+
411
+ return followed_ceiling.include?(required_scope.to_s)
412
+ end
413
+
364
414
  if scopes.blank?
365
415
  return !scope_policy_enabled?
366
416
  end
@@ -401,6 +451,50 @@ module ApiKeys
401
451
  ceiling == :all || ceiling.include?(scope.to_s)
402
452
  end
403
453
 
454
+ # The scopes to save after a form that offered only some scopes as
455
+ # checkboxes was submitted: the rule the dashboard's edit form uses, for
456
+ # host forms built with `api_key_scopes_checkboxes`.
457
+ #
458
+ # - Offered scopes are set from the boxes: checked ones are granted,
459
+ # unchecked ones removed.
460
+ # - Every other scope the key holds is kept, in its current order.
461
+ # - A checked value that was never offered is ignored, so a crafted
462
+ # request cannot grant it.
463
+ # - A held scope outside the key's current ceiling is dropped, offered or
464
+ # not. Authentication already denies it, and keeping it would make every
465
+ # save of the key fail the ceiling validation. A newly checked scope
466
+ # outside the ceiling is kept, so the validation reports it.
467
+ # - `checked: nil` means the form submitted no scopes at all (no boxes and
468
+ # no sentinel): nothing is granted or removed except held scopes outside
469
+ # the ceiling.
470
+ # - A `scopes: :ceiling` key returns its stored scopes unchanged: they are
471
+ # metadata there, and the type's live ceiling authorizes the key.
472
+ #
473
+ # It only computes the list; assign it (`key.update(scopes: ...)`) to save.
474
+ #
475
+ # @param offered [Array<String, Symbol>] The scopes the form rendered as boxes.
476
+ # @param checked [Array<String, Symbol>, nil] The submitted values; blank
477
+ # entries (the sentinel) are ignored.
478
+ # @return [Array<String>]
479
+ # @raise [ArgumentError] when +offered+ or +checked+ is not an Array (or nil for +checked+).
480
+ def scopes_after_form_edit(offered:, checked:)
481
+ raise ArgumentError, "offered must be an Array of scope names" unless offered.is_a?(Array)
482
+ unless checked.nil? || checked.is_a?(Array)
483
+ raise ArgumentError, "checked must be an Array of scope names, or nil when no scopes were submitted"
484
+ end
485
+ return scopes.deep_dup if scopes_follow_ceiling?
486
+
487
+ offered = checked.nil? ? [] : offered.map(&:to_s)
488
+ checked = Array(checked).reject { |scope| scope.respond_to?(:blank?) && scope.blank? }.map(&:to_s)
489
+ held = Array(scopes)
490
+
491
+ kept = held.select do |scope|
492
+ scope_within_ceiling?(scope) && (!offered.include?(scope) || checked.include?(scope))
493
+ end
494
+ added = offered.select { |scope| checked.include?(scope) && !held.include?(scope) }
495
+ kept + added
496
+ end
497
+
404
498
  # Alias for scopes - provides a more user-friendly API that matches
405
499
  # the configuration DSL where key types use `permissions` for scope ceiling.
406
500
  # Note: We use a method instead of alias_method because `scopes` is defined
@@ -417,6 +511,52 @@ module ApiKeys
417
511
  allows_scope?(required_permission)
418
512
  end
419
513
 
514
+ # Whether scope checks for this key read its type's live permission ceiling
515
+ # instead of the scopes stored at creation (`scopes: :ceiling` key types).
516
+ # Untyped keys, keys whose type is no longer configured, and every other
517
+ # type answer false and keep the stored-scope behavior.
518
+ # @return [Boolean]
519
+ def scopes_follow_ceiling?
520
+ return false if key_type.blank?
521
+
522
+ self.class.scopes_follow_ceiling_for?(key_type_config)
523
+ end
524
+
525
+ # == Refused-but-identified traffic ==
526
+ # Stamped asynchronously when a request presents this key's real token and a
527
+ # policy refuses it. Installations that have not run
528
+ # `rails generate api_keys:add_key_activity` read nil / 0 instead of raising.
529
+
530
+ # @return [Time, nil] When the most recently recorded refusal happened.
531
+ def last_refused_at
532
+ self.class.refusal_stats_columns? ? self[:last_refused_at] : nil
533
+ end
534
+
535
+ # @return [Integer] Refusals counted while `track_requests_count` is on.
536
+ def refusals_count
537
+ self.class.refusal_stats_columns? ? self[:refusals_count].to_i : 0
538
+ end
539
+
540
+ # @return [String, nil] The error code of the most recently recorded refusal.
541
+ def last_refusal_code
542
+ self.class.refusal_stats_columns? ? self[:last_refusal_code] : nil
543
+ end
544
+
545
+ # == One-time token handoff ==
546
+
547
+ # When this key's token was released through the one-time handoff
548
+ # (ApiKeys::TokenSession). Nil when it never was, and on installations that
549
+ # have not run `rails generate api_keys:add_key_activity` yet.
550
+ # @return [Time, nil]
551
+ def token_shown_at
552
+ self.class.token_shown_at_column? ? self[:token_shown_at] : nil
553
+ end
554
+
555
+ # @return [Boolean] Whether the one-time handoff already released this key's token.
556
+ def token_shown?
557
+ !token_shown_at.nil?
558
+ end
559
+
420
560
  # Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
421
561
  # Requires the plaintext token to be available (only right after creation).
422
562
  def masked_token
@@ -442,6 +582,114 @@ module ApiKeys
442
582
  false
443
583
  end
444
584
 
585
+ # Whether the `created_by` columns exist. Installations that predate
586
+ # v0.7.0 keep working untouched until they run the generator.
587
+ # @return [Boolean]
588
+ def self.created_by_columns?
589
+ (CREATED_BY_COLUMNS - column_names).empty?
590
+ rescue StandardError
591
+ false
592
+ end
593
+
594
+ # Whether the refused-traffic columns exist. Installations that predate
595
+ # v0.6.0 keep authenticating untouched until they run the generator.
596
+ # @return [Boolean]
597
+ def self.refusal_stats_columns?
598
+ (REFUSAL_STATS_COLUMNS - column_names).empty?
599
+ rescue StandardError
600
+ false
601
+ end
602
+
603
+ # Whether the `token_shown_at` column exists. Without it the one-time
604
+ # handoff falls back to the cache marker alone, as in 0.5.1.
605
+ # @return [Boolean]
606
+ def self.token_shown_at_column?
607
+ column_names.include?(TOKEN_SHOWN_AT_COLUMN)
608
+ rescue StandardError
609
+ false
610
+ end
611
+
612
+ # Durably claims the one-time token handoff of the key with +id+ in one
613
+ # conditional statement:
614
+ #
615
+ # UPDATE api_keys SET token_shown_at = now WHERE id = ? AND token_shown_at IS NULL
616
+ #
617
+ # The database applies it atomically, so exactly one caller ever gets true
618
+ # for a key: across processes, across copies of a session, and after the
619
+ # cache is flushed. Zero rows changed means the token was already shown or
620
+ # the key no longer exists, and answers false (fail closed).
621
+ #
622
+ # Answers nil when the column does not exist yet (nothing can be recorded
623
+ # durably). Database errors are raised, not rescued: the caller withholds
624
+ # the token.
625
+ #
626
+ # @param id [Integer, String] The key's primary key.
627
+ # @return [Boolean, nil]
628
+ def self.claim_token_handoff!(id)
629
+ return nil unless column_names.include?(TOKEN_SHOWN_AT_COLUMN)
630
+ return false if id.nil?
631
+
632
+ where(primary_key => id, TOKEN_SHOWN_AT_COLUMN => nil)
633
+ .update_all(TOKEN_SHOWN_AT_COLUMN => Time.current) == 1
634
+ end
635
+
636
+ # Read-only companion of `claim_token_handoff!`: whether a claim for +id+
637
+ # could still succeed. Nil without the column. Raises on database errors.
638
+ #
639
+ # @param id [Integer, String] The key's primary key.
640
+ # @return [Boolean, nil]
641
+ def self.token_handoff_claimable?(id)
642
+ return nil unless column_names.include?(TOKEN_SHOWN_AT_COLUMN)
643
+ return false if id.nil?
644
+
645
+ where(primary_key => id, TOKEN_SHOWN_AT_COLUMN => nil).exists?
646
+ end
647
+
648
+ # Single source of truth for `scopes: :ceiling`. The finite-ceiling check
649
+ # repeats the configuration validation on purpose: a ceiling of `:all` must
650
+ # never become the contract, however the configuration was produced.
651
+ def self.scopes_follow_ceiling_for?(type_config)
652
+ return false unless type_config.is_a?(Hash) && type_config[:scopes].to_s == "ceiling"
653
+
654
+ permissions = type_config[:permissions]
655
+ permissions.is_a?(Array) && permissions.any?
656
+ end
657
+
658
+ # Add-only merge of a key type's current permission ceiling into the stored
659
+ # scopes of every non-revoked key of that type: the backfill for types that
660
+ # keep stored scopes after their ceiling was widened. It only ever adds
661
+ # permissions the type already allows, and it re-adds any a user removed
662
+ # by hand, so run it only for types whose users do not curate scopes.
663
+ #
664
+ # @param key_type [Symbol, String] A configured type with a finite ceiling.
665
+ # @return [Integer] How many keys gained scopes.
666
+ def self.reconcile_scopes!(key_type:)
667
+ type_name, type_config = ApiKeys.configuration.key_types.find { |name, _settings| name.to_s == key_type.to_s }
668
+ raise ArgumentError, "Unknown key type '#{key_type}'" unless type_config
669
+
670
+ ceiling = type_config[:permissions]
671
+ unless ceiling.is_a?(Array) && ceiling.any?
672
+ raise ArgumentError, "Key type '#{key_type}' has no finite permission ceiling to reconcile"
673
+ end
674
+
675
+ ceiling = ceiling.map(&:to_s)
676
+ where(key_type: type_name.to_s, revoked_at: nil).find_each.count do |api_key|
677
+ api_key.with_lock do
678
+ # with_lock reloads the row: a key revoked (or retyped) after it was
679
+ # selected is skipped, so a revoked key never gains scopes.
680
+ next false if api_key.revoked_at.present? || api_key.key_type.to_s != type_name.to_s
681
+
682
+ stored = api_key.scopes
683
+ next false unless stored.is_a?(Array) && stored.all? { |scope| api_key.send(:valid_scope_value?, scope) }
684
+
685
+ merged = stored | ceiling
686
+ next false if merged == stored || merged.length > MAX_SCOPES
687
+
688
+ api_key.update_columns(scopes: merged, updated_at: Time.current)
689
+ end
690
+ end
691
+ end
692
+
445
693
  # Single source of truth for a key type's request-restriction ceiling.
446
694
  # Omitting the setting allows every supported restriction kind.
447
695
  def self.restriction_kinds_for(type_config)
@@ -706,6 +954,11 @@ module ApiKeys
706
954
 
707
955
  def scopes_respect_permission_ceiling
708
956
  return unless scopes.is_a?(Array)
957
+ # On a `scopes: :ceiling` key the stored list authorizes nothing. Narrowing
958
+ # the ceiling must not leave every existing key of the type unsaveable
959
+ # (which would also block tightening its request restrictions), so only
960
+ # writes to the scopes themselves are held to the current ceiling.
961
+ return if persisted? && scopes_follow_ceiling? && !will_save_change_to_attribute?(:scopes)
709
962
 
710
963
  ceiling = permission_ceiling
711
964
  return if ceiling == :all
@@ -754,6 +1007,11 @@ module ApiKeys
754
1007
  false
755
1008
  end
756
1009
 
1010
+ # The live ceiling a `scopes: :ceiling` key is authorized for.
1011
+ def followed_ceiling
1012
+ Array(key_type_config&.[](:permissions)).map(&:to_s)
1013
+ end
1014
+
757
1015
  def permission_ceiling
758
1016
  if key_type.present?
759
1017
  config = key_type_config
@@ -225,13 +225,20 @@ module ApiKeys
225
225
  # Accepts the raw string a form field submits ("example.com, *.example.com").
226
226
  # @param allowed_ips [String, Array, nil] Convenience form of the IP list
227
227
  # ("203.0.113.7, 10.0.0.0/8").
228
+ # @param created_by [ActiveRecord::Base, nil] The person behind the key, for
229
+ # `config.trusted_key_check`. Needs the created_by columns.
228
230
  # @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
229
231
  # is available via the `#token` attribute on this instance
230
232
  # *only until it's reloaded*.
231
233
  def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil,
232
- key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil)
234
+ key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil,
235
+ created_by: nil)
233
236
  config = ApiKeys.configuration
234
237
 
238
+ if created_by && !ApiKeys::ApiKey.created_by_columns?
239
+ raise ApiKeys::Errors::CreatedByMigrationRequiredError
240
+ end
241
+
235
242
  # Parse expires_at_preset if provided (takes precedence over expires_at)
236
243
  if expires_at_preset.present?
237
244
  expires_at = ApiKeys::Helpers::ExpirationOptions.parse(expires_at_preset)
@@ -302,6 +309,7 @@ module ApiKeys
302
309
  # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
303
310
  }
304
311
  attributes[:restrictions] = requested_restrictions if requested_restrictions
312
+ attributes[:created_by] = created_by if created_by
305
313
 
306
314
  api_key = self.class.transaction do
307
315
  self.api_keys.create!(**attributes)