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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +158 -9
- data/SECURITY.md +22 -2
- data/app/controllers/api_keys/keys_controller.rb +136 -14
- data/app/views/api_keys/keys/_form.html.erb +85 -17
- data/app/views/api_keys/keys/_key_row.html.erb +4 -2
- data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +4 -2
- data/app/views/api_keys/keys/_show_token.html.erb +4 -7
- data/app/views/api_keys/keys/edit.html.erb +2 -0
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
- data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
- data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
- data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
- data/lib/api_keys/authentication.rb +123 -7
- data/lib/api_keys/configuration.rb +37 -6
- data/lib/api_keys/form_builder_extensions.rb +42 -3
- data/lib/api_keys/helpers/token_session.rb +257 -8
- data/lib/api_keys/helpers/view_helpers.rb +33 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +293 -7
- data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
- data/lib/api_keys/restrictions.rb +75 -24
- data/lib/api_keys/services/authenticator.rb +24 -2
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +7 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
- data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +12 -0
- data/lib/generators/api_keys/templates/initializer.rb +32 -7
- 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;
|
|
68
|
-
#
|
|
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
|
-
|
|
76
|
-
wrapped
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
141
|
-
|
|
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
|
|
194
|
-
#
|
|
195
|
-
#
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
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)
|
|
290
|
-
hash["ips"] = deep_copy(ips)
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
data/lib/api_keys/version.rb
CHANGED
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
|
|