api_keys 0.4.3 → 0.5.1
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 +39 -0
- data/README.md +195 -27
- data/SECURITY.md +21 -2
- data/app/controllers/api_keys/keys_controller.rb +137 -11
- data/app/views/api_keys/keys/_form.html.erb +80 -17
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- 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/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +54 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +40 -3
- data/lib/api_keys/helpers/token_session.rb +92 -3
- data/lib/api_keys/models/api_key.rb +240 -11
- data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
- data/lib/api_keys/restrictions.rb +430 -0
- data/lib/api_keys/services/authenticator.rb +110 -12
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +1 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
- data/lib/generators/api_keys/templates/initializer.rb +49 -6
- metadata +8 -1
|
@@ -4,6 +4,7 @@ require "active_record"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "../services/token_generator"
|
|
6
6
|
require_relative "../services/digestor"
|
|
7
|
+
require_relative "../restrictions"
|
|
7
8
|
|
|
8
9
|
module ApiKeys
|
|
9
10
|
# The core ActiveRecord model representing an API key.
|
|
@@ -11,6 +12,11 @@ module ApiKeys
|
|
|
11
12
|
MAX_SCOPES = 100
|
|
12
13
|
MAX_SCOPE_BYTESIZE = 128
|
|
13
14
|
MAX_METADATA_BYTESIZE = 16_384
|
|
15
|
+
MAX_RESTRICTION_ENTRIES = 100
|
|
16
|
+
MAX_RESTRICTION_ENTRY_BYTESIZE = 255
|
|
17
|
+
RESTRICTIONS_COLUMN = "restrictions"
|
|
18
|
+
# Deliberately excludes `restrictions`: owners must be able to tighten a
|
|
19
|
+
# request policy even when the key itself is non-revocable.
|
|
14
20
|
IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
|
|
15
21
|
token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
|
|
16
22
|
].freeze
|
|
@@ -26,6 +32,7 @@ module ApiKeys
|
|
|
26
32
|
# == Attributes & Serialization ==
|
|
27
33
|
# Expose the plaintext token only immediately after creation
|
|
28
34
|
attr_reader :token
|
|
35
|
+
attr_accessor :expires_at_preset
|
|
29
36
|
|
|
30
37
|
# JSON attributes (:scopes, :metadata) are defined in the engine initializer
|
|
31
38
|
# using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
|
|
@@ -45,6 +52,64 @@ module ApiKeys
|
|
|
45
52
|
super(cleaned)
|
|
46
53
|
end
|
|
47
54
|
|
|
55
|
+
# == Request Restrictions ==
|
|
56
|
+
# Where this key may be used from. Reads always answer with a value object,
|
|
57
|
+
# so `key.restrictions.origins` works even on a key that has none.
|
|
58
|
+
#
|
|
59
|
+
# @return [ApiKeys::Restrictions]
|
|
60
|
+
def restrictions
|
|
61
|
+
return ApiKeys::Restrictions.none unless self.class.restrictions_column?
|
|
62
|
+
|
|
63
|
+
ApiKeys::Restrictions.wrap(self[:restrictions])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Accepts a Restrictions instance, a hash of lists, or nil. Hashes are
|
|
67
|
+
# normalized into the storage shape; a kind given as `nil` or blank text
|
|
68
|
+
# is cleared, as the list writers do. Anything malformed is stored
|
|
69
|
+
# untouched so the validation, rather than a silent coercion, reports it.
|
|
70
|
+
def restrictions=(value)
|
|
71
|
+
ensure_restrictions_column!
|
|
72
|
+
|
|
73
|
+
normalized = if value.nil?
|
|
74
|
+
{}
|
|
75
|
+
elsif value.is_a?(Hash)
|
|
76
|
+
input = ApiKeys::Restrictions.normalize_input(value)
|
|
77
|
+
wrapped = ApiKeys::Restrictions.wrap(input)
|
|
78
|
+
wrapped.malformed? ? input : wrapped.to_h
|
|
79
|
+
elsif value.is_a?(ApiKeys::Restrictions)
|
|
80
|
+
value.malformed? ? { "__malformed__" => true } : value.to_h
|
|
81
|
+
else
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
super(normalized)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Array<String>] Allowed web origins (hosts and `*.host` wildcards).
|
|
88
|
+
def allowed_origins
|
|
89
|
+
restrictions.origins
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Array<String>] Allowed IP addresses and CIDR ranges.
|
|
93
|
+
def allowed_ips
|
|
94
|
+
restrictions.ips
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Accepts an array or a raw string ("example.com, *.example.com") and
|
|
98
|
+
# normalizes it, so host applications never need their own parser.
|
|
99
|
+
def allowed_origins=(value)
|
|
100
|
+
self.restrictions = restrictions.to_h.merge("origins" => ApiKeys::Restrictions.normalize_origins(value))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Accepts an array or a raw string ("203.0.113.7, 10.0.0.0/8").
|
|
104
|
+
def allowed_ips=(value)
|
|
105
|
+
self.restrictions = restrictions.to_h.merge("ips" => ApiKeys::Restrictions.normalize_ips(value))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Boolean] true when this key carries any request restriction.
|
|
109
|
+
def restricted?
|
|
110
|
+
restrictions.restricted?
|
|
111
|
+
end
|
|
112
|
+
|
|
48
113
|
# == Validations ==
|
|
49
114
|
validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
|
|
50
115
|
validates :prefix, presence: true, length: { maximum: 64 }
|
|
@@ -73,6 +138,8 @@ module ApiKeys
|
|
|
73
138
|
validate :token_digest_matches_algorithm
|
|
74
139
|
validate :token_identifiers_are_well_formed
|
|
75
140
|
validate :metadata_is_well_formed
|
|
141
|
+
validate :restrictions_are_well_formed
|
|
142
|
+
validate :restrictions_respect_key_type, if: -> { key_type.present? }
|
|
76
143
|
validate :authentication_identity_is_immutable, on: :update
|
|
77
144
|
|
|
78
145
|
# TODO: Add validation for scope string format
|
|
@@ -101,7 +168,15 @@ module ApiKeys
|
|
|
101
168
|
# .publishable returns only keys with key_type: "publishable"
|
|
102
169
|
# .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type)
|
|
103
170
|
scope :publishable, -> { where(key_type: "publishable") }
|
|
104
|
-
|
|
171
|
+
# SQL `!=` excludes NULL, so include pre-key-types rows explicitly. Those
|
|
172
|
+
# legacy credentials have always had secret-key capabilities.
|
|
173
|
+
scope :secret, -> {
|
|
174
|
+
where(key_type: nil).or(where.not(key_type: "publishable"))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Keys that carry request restrictions, and keys usable from anywhere.
|
|
178
|
+
scope :restricted, -> { where.not(restrictions: [nil, {}]) }
|
|
179
|
+
scope :unrestricted, -> { where(restrictions: [nil, {}]) }
|
|
105
180
|
|
|
106
181
|
# === Usage Analytics Scopes ===
|
|
107
182
|
# These scopes help admin dashboards analyze API key usage patterns.
|
|
@@ -139,9 +214,30 @@ module ApiKeys
|
|
|
139
214
|
|
|
140
215
|
# == Instance Methods ==
|
|
141
216
|
|
|
217
|
+
# Revocation is the emergency control, so it must not hinge on whether the
|
|
218
|
+
# rest of the row still satisfies today's policy. A key issued before its
|
|
219
|
+
# type's permission or restriction ceiling was narrowed keeps
|
|
220
|
+
# authenticating within the narrower ceiling; refusing to revoke it would
|
|
221
|
+
# leave a credential nobody can switch off.
|
|
222
|
+
#
|
|
223
|
+
# The revocability guard always applies. When the revocation is the only
|
|
224
|
+
# pending change, the row is saved without validations: only `revoked_at`
|
|
225
|
+
# (and `updated_at`) are written, and save callbacks run as before. Other
|
|
226
|
+
# unsaved edits on the same instance are never written unvalidated: in
|
|
227
|
+
# that case the whole save is validated, exactly as before 0.5.1.
|
|
228
|
+
#
|
|
229
|
+
# The authenticator's token cache holds only a key ID and reloads the row
|
|
230
|
+
# on every hit, so the next request is refused as revoked with no cache
|
|
231
|
+
# invalidation needed.
|
|
142
232
|
def revoke!
|
|
143
233
|
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
144
|
-
|
|
234
|
+
|
|
235
|
+
self.revoked_at = Time.current
|
|
236
|
+
if persisted? && (changes_to_save.keys - %w[revoked_at]).empty?
|
|
237
|
+
save!(validate: false)
|
|
238
|
+
else
|
|
239
|
+
save!
|
|
240
|
+
end
|
|
145
241
|
end
|
|
146
242
|
|
|
147
243
|
def revoked?
|
|
@@ -201,9 +297,12 @@ module ApiKeys
|
|
|
201
297
|
# Keys with a key_type check the configuration
|
|
202
298
|
def revocable?
|
|
203
299
|
return true if key_type.blank?
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
300
|
+
self.class.revocable_for(key_type_config)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Non-revocable keys are permanent by design; other key types may expire.
|
|
304
|
+
def expirable?
|
|
305
|
+
revocable?
|
|
207
306
|
end
|
|
208
307
|
|
|
209
308
|
# Returns the configuration hash for this key's type
|
|
@@ -220,17 +319,17 @@ module ApiKeys
|
|
|
220
319
|
configured_pair&.last
|
|
221
320
|
end
|
|
222
321
|
|
|
223
|
-
# Returns true if this key type is configured as public
|
|
224
|
-
# Only these keys have their plaintext token stored
|
|
322
|
+
# Returns true if this key type is explicitly configured as public.
|
|
323
|
+
# Only these keys have their plaintext token stored for later viewing.
|
|
225
324
|
# This is used for publishable keys that are designed to be embedded in distributed apps.
|
|
226
325
|
def public_key_type?
|
|
227
326
|
return false if key_type.blank?
|
|
228
327
|
config = key_type_config
|
|
229
328
|
return false if config.nil?
|
|
230
|
-
config[:public] == true
|
|
329
|
+
config[:public] == true
|
|
231
330
|
end
|
|
232
331
|
|
|
233
|
-
# Returns the stored plaintext token for public
|
|
332
|
+
# Returns the stored plaintext token for public keys.
|
|
234
333
|
# Returns nil for all other key types (the token is only available at creation time).
|
|
235
334
|
# @return [String, nil] The full plaintext token, or nil if not stored
|
|
236
335
|
def viewable_token
|
|
@@ -274,6 +373,34 @@ module ApiKeys
|
|
|
274
373
|
ceiling == :all || ceiling.include?(required)
|
|
275
374
|
end
|
|
276
375
|
|
|
376
|
+
# Whether this key's scope list is enforced, so an empty list grants no
|
|
377
|
+
# permissions. True for typed keys, whenever `key_types` or the global
|
|
378
|
+
# `default_scopes` are configured, and when the owner model's
|
|
379
|
+
# `has_api_keys` declares `default_scopes`. The last case is read through
|
|
380
|
+
# `owner`, so a key that does not know its owner yet (a bare `ApiKey.new`)
|
|
381
|
+
# cannot see it: build keys from their owner (`owner.api_keys.build`).
|
|
382
|
+
# False otherwise, where an empty list keeps its legacy meaning:
|
|
383
|
+
# unrestricted. Forms use it to decide whether "every box cleared" can be
|
|
384
|
+
# submitted as an empty list without widening the key.
|
|
385
|
+
#
|
|
386
|
+
# @return [Boolean]
|
|
387
|
+
def scopes_enforced?
|
|
388
|
+
scope_policy_enabled?
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Whether a scope is inside this key's current permission ceiling: its
|
|
392
|
+
# type's `permissions:`, or the configured scopes for an untyped key.
|
|
393
|
+
# A held scope outside it (left behind when a ceiling was narrowed) is
|
|
394
|
+
# already denied by `allows_scope?` and fails the ceiling validation on
|
|
395
|
+
# the next save.
|
|
396
|
+
#
|
|
397
|
+
# @param scope [String, Symbol]
|
|
398
|
+
# @return [Boolean]
|
|
399
|
+
def scope_within_ceiling?(scope)
|
|
400
|
+
ceiling = permission_ceiling
|
|
401
|
+
ceiling == :all || ceiling.include?(scope.to_s)
|
|
402
|
+
end
|
|
403
|
+
|
|
277
404
|
# Alias for scopes - provides a more user-friendly API that matches
|
|
278
405
|
# the configuration DSL where key types use `permissions` for scope ceiling.
|
|
279
406
|
# Note: We use a method instead of alias_method because `scopes` is defined
|
|
@@ -306,8 +433,41 @@ module ApiKeys
|
|
|
306
433
|
# == Class Methods ==
|
|
307
434
|
# Most creation logic is handled by standard ActiveRecord methods + callbacks
|
|
308
435
|
|
|
436
|
+
# Whether the `restrictions` column exists. Installations that predate
|
|
437
|
+
# v0.5.0 keep working untouched until they run the generator.
|
|
438
|
+
# @return [Boolean]
|
|
439
|
+
def self.restrictions_column?
|
|
440
|
+
column_names.include?(RESTRICTIONS_COLUMN)
|
|
441
|
+
rescue StandardError
|
|
442
|
+
false
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Single source of truth for a key type's request-restriction ceiling.
|
|
446
|
+
# Omitting the setting allows every supported restriction kind.
|
|
447
|
+
def self.restriction_kinds_for(type_config)
|
|
448
|
+
return ApiKeys::Restrictions::KINDS.dup unless type_config.is_a?(Hash) && type_config.key?(:restrictions)
|
|
449
|
+
|
|
450
|
+
Array(type_config[:restrictions]).map(&:to_sym)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Single source of truth for lifecycle policy in model and dashboard code.
|
|
454
|
+
def self.revocable_for(type_config)
|
|
455
|
+
type_config.is_a?(Hash) && type_config.fetch(:revocable, true)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# @return [Array<Symbol>] Restriction kinds this key's type permits.
|
|
459
|
+
def allowed_restriction_kinds
|
|
460
|
+
self.class.restriction_kinds_for(key_type_config)
|
|
461
|
+
end
|
|
462
|
+
|
|
309
463
|
private
|
|
310
464
|
|
|
465
|
+
def ensure_restrictions_column!
|
|
466
|
+
return if self.class.restrictions_column?
|
|
467
|
+
|
|
468
|
+
raise ApiKeys::Errors::RestrictionsMigrationRequiredError
|
|
469
|
+
end
|
|
470
|
+
|
|
311
471
|
# Set defaults for attributes not handled by the `attribute` API in the engine.
|
|
312
472
|
def set_defaults
|
|
313
473
|
# NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
|
|
@@ -390,10 +550,11 @@ module ApiKeys
|
|
|
390
550
|
self.expires_at = ApiKeys.configuration.expire_after.from_now
|
|
391
551
|
end
|
|
392
552
|
|
|
393
|
-
# Store plaintext token in metadata for public
|
|
553
|
+
# Store plaintext token in metadata for explicitly public keys.
|
|
394
554
|
# This allows users to view the token again in the dashboard.
|
|
395
555
|
# SECURITY: Only do this for keys explicitly configured as public: true
|
|
396
|
-
#
|
|
556
|
+
# Public key types must have a finite permission ceiling, but may be
|
|
557
|
+
# revocable and expirable like any other credential.
|
|
397
558
|
if public_key_type?
|
|
398
559
|
self.metadata = (self.metadata || {}).merge("token" => @token)
|
|
399
560
|
end
|
|
@@ -467,6 +628,74 @@ module ApiKeys
|
|
|
467
628
|
errors.add(:metadata, "must contain valid JSON data")
|
|
468
629
|
end
|
|
469
630
|
|
|
631
|
+
# Restrictions are security policy: a malformed list must fail loudly at
|
|
632
|
+
# write time rather than quietly protecting nothing at authentication time.
|
|
633
|
+
def restrictions_are_well_formed
|
|
634
|
+
return unless self.class.restrictions_column?
|
|
635
|
+
|
|
636
|
+
raw = self[:restrictions]
|
|
637
|
+
return if raw.nil?
|
|
638
|
+
|
|
639
|
+
unless raw.is_a?(Hash)
|
|
640
|
+
errors.add(:restrictions, "must be an object")
|
|
641
|
+
return
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES
|
|
645
|
+
if unknown_kinds.any?
|
|
646
|
+
errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}")
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
validate_restriction_list(:origins) { |entry| ApiKeys::Restrictions.valid_origin_entry?(entry) }
|
|
650
|
+
validate_restriction_list(:ips) { |entry| ApiKeys::Restrictions.valid_ip_entry?(entry) }
|
|
651
|
+
|
|
652
|
+
# Whatever made the policy malformed, it never saves: a policy that
|
|
653
|
+
# authentication would refuse must not be written in the first place.
|
|
654
|
+
if restrictions.malformed? && errors[:restrictions].empty?
|
|
655
|
+
errors.add(:restrictions, "must contain valid restriction data")
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def validate_restriction_list(kind)
|
|
660
|
+
entries = restrictions.public_send(kind)
|
|
661
|
+
|
|
662
|
+
if entries.length > MAX_RESTRICTION_ENTRIES
|
|
663
|
+
errors.add(:restrictions, "#{kind} cannot contain more than #{MAX_RESTRICTION_ENTRIES} entries")
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
if entries.any? { |entry| !valid_restriction_entry_size?(entry) }
|
|
667
|
+
errors.add(:restrictions, "#{kind} entries cannot exceed #{MAX_RESTRICTION_ENTRY_BYTESIZE} bytes")
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
return if entries.all? { |entry| yield(entry) }
|
|
671
|
+
|
|
672
|
+
message = if kind == :origins
|
|
673
|
+
"origins must be bare hosts like example.com or *.example.com"
|
|
674
|
+
else
|
|
675
|
+
"ips must be valid IPv4/IPv6 addresses or CIDR ranges"
|
|
676
|
+
end
|
|
677
|
+
errors.add(:restrictions, message)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# Non-string entries are reported by the shape check below, not here.
|
|
681
|
+
def valid_restriction_entry_size?(entry)
|
|
682
|
+
return true unless entry.is_a?(String)
|
|
683
|
+
return false unless entry.valid_encoding?
|
|
684
|
+
|
|
685
|
+
entry.bytesize <= MAX_RESTRICTION_ENTRY_BYTESIZE
|
|
686
|
+
rescue ArgumentError
|
|
687
|
+
false
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Key types may declare a ceiling on the restriction kinds their keys carry,
|
|
691
|
+
# mirroring the way `permissions:` caps scopes.
|
|
692
|
+
def restrictions_respect_key_type
|
|
693
|
+
exceeded = restrictions.kinds - allowed_restriction_kinds
|
|
694
|
+
return if exceeded.empty?
|
|
695
|
+
|
|
696
|
+
errors.add(:restrictions, "#{exceeded.sort.join(', ')} are not allowed for #{key_type} keys")
|
|
697
|
+
end
|
|
698
|
+
|
|
470
699
|
def authentication_identity_is_immutable
|
|
471
700
|
IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name|
|
|
472
701
|
next unless will_save_change_to_attribute?(attribute_name)
|
|
@@ -219,10 +219,17 @@ module ApiKeys
|
|
|
219
219
|
# Must be defined in ApiKeys.configuration.key_types if provided.
|
|
220
220
|
# @param environment [Symbol, nil] The environment (e.g., :test, :live).
|
|
221
221
|
# Defaults to current_environment if key_types feature is enabled.
|
|
222
|
+
# @param restrictions [Hash, ApiKeys::Restrictions, nil] Request restrictions in
|
|
223
|
+
# storage shape, e.g. { origins: ["example.com"], ips: ["10.0.0.0/8"] }.
|
|
224
|
+
# @param allowed_origins [String, Array, nil] Convenience form of the origins list.
|
|
225
|
+
# Accepts the raw string a form field submits ("example.com, *.example.com").
|
|
226
|
+
# @param allowed_ips [String, Array, nil] Convenience form of the IP list
|
|
227
|
+
# ("203.0.113.7, 10.0.0.0/8").
|
|
222
228
|
# @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
|
|
223
229
|
# is available via the `#token` attribute on this instance
|
|
224
230
|
# *only until it's reloaded*.
|
|
225
|
-
def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil,
|
|
231
|
+
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)
|
|
226
233
|
config = ApiKeys.configuration
|
|
227
234
|
|
|
228
235
|
# Parse expires_at_preset if provided (takes precedence over expires_at)
|
|
@@ -240,6 +247,13 @@ module ApiKeys
|
|
|
240
247
|
check_required_columns!
|
|
241
248
|
end
|
|
242
249
|
|
|
250
|
+
# Requesting restrictions (directly, or through a key type that
|
|
251
|
+
# declares a ceiling) requires the column that stores them.
|
|
252
|
+
requested_restrictions = build_restrictions(restrictions, allowed_origins, allowed_ips)
|
|
253
|
+
if requested_restrictions || restriction_ceilings_configured?(config)
|
|
254
|
+
check_restrictions_column!
|
|
255
|
+
end
|
|
256
|
+
|
|
243
257
|
# Use default_key_type if not specified and key_types feature is enabled
|
|
244
258
|
resolved_key_type = key_type
|
|
245
259
|
if resolved_key_type.nil? && key_types_feature_enabled?(config) && config.default_key_type.present?
|
|
@@ -278,16 +292,19 @@ module ApiKeys
|
|
|
278
292
|
# ApiKey's creation callback locks the owner row before quota validation.
|
|
279
293
|
# Keep an explicit transaction here so the helper's creation workflow is
|
|
280
294
|
# a single atomic unit; direct ApiKey.create! calls are protected too.
|
|
295
|
+
attributes = {
|
|
296
|
+
name: name,
|
|
297
|
+
scopes: key_scopes,
|
|
298
|
+
expires_at: expires_at,
|
|
299
|
+
metadata: metadata || {}, # Ensure metadata is at least an empty hash
|
|
300
|
+
key_type: resolved_key_type&.to_s,
|
|
301
|
+
environment: resolved_environment&.to_s
|
|
302
|
+
# prefix, token_digest, digest_algorithm are set by ApiKey callbacks
|
|
303
|
+
}
|
|
304
|
+
attributes[:restrictions] = requested_restrictions if requested_restrictions
|
|
305
|
+
|
|
281
306
|
api_key = self.class.transaction do
|
|
282
|
-
self.api_keys.create!(
|
|
283
|
-
name: name,
|
|
284
|
-
scopes: key_scopes,
|
|
285
|
-
expires_at: expires_at,
|
|
286
|
-
metadata: metadata || {}, # Ensure metadata is at least an empty hash
|
|
287
|
-
key_type: resolved_key_type&.to_s,
|
|
288
|
-
environment: resolved_environment&.to_s
|
|
289
|
-
# prefix, token_digest, digest_algorithm are set by ApiKey callbacks
|
|
290
|
-
)
|
|
307
|
+
self.api_keys.create!(**attributes)
|
|
291
308
|
end
|
|
292
309
|
|
|
293
310
|
# Return the ApiKey instance itself.
|
|
@@ -357,6 +374,38 @@ module ApiKeys
|
|
|
357
374
|
scopes.select { |scope| permissions.include?(scope.to_s) }
|
|
358
375
|
end
|
|
359
376
|
|
|
377
|
+
# Merges the three ways a caller can express restrictions into one
|
|
378
|
+
# storage hash. Returns nil when the caller asked for none of them, so
|
|
379
|
+
# the column keeps its default and legacy installs stay untouched.
|
|
380
|
+
#
|
|
381
|
+
# @return [Hash, nil]
|
|
382
|
+
def build_restrictions(restrictions, allowed_origins, allowed_ips)
|
|
383
|
+
return nil if restrictions.nil? && allowed_origins.nil? && allowed_ips.nil?
|
|
384
|
+
|
|
385
|
+
unless restrictions.nil? || restrictions.is_a?(Hash) || restrictions.is_a?(ApiKeys::Restrictions)
|
|
386
|
+
raise ArgumentError, "restrictions must be a Hash or ApiKeys::Restrictions"
|
|
387
|
+
end
|
|
388
|
+
|
|
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
|
|
398
|
+
attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil?
|
|
399
|
+
attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil?
|
|
400
|
+
attributes
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# True when any configured key type declares a restriction ceiling.
|
|
404
|
+
def restriction_ceilings_configured?(config)
|
|
405
|
+
config.key_types.present? &&
|
|
406
|
+
config.key_types.any? { |_type, settings| settings.is_a?(Hash) && settings.key?(:restrictions) }
|
|
407
|
+
end
|
|
408
|
+
|
|
360
409
|
# Check that required columns exist for key_types feature
|
|
361
410
|
# Raises MigrationRequiredError if columns are missing
|
|
362
411
|
def check_required_columns!
|
|
@@ -370,6 +419,14 @@ module ApiKeys
|
|
|
370
419
|
end
|
|
371
420
|
end
|
|
372
421
|
|
|
422
|
+
# Check that the restrictions column exists before writing to it.
|
|
423
|
+
# Raises RestrictionsMigrationRequiredError naming the generator.
|
|
424
|
+
def check_restrictions_column!
|
|
425
|
+
return if ApiKeys::ApiKey.restrictions_column?
|
|
426
|
+
|
|
427
|
+
raise ApiKeys::Errors::RestrictionsMigrationRequiredError
|
|
428
|
+
end
|
|
429
|
+
|
|
373
430
|
# Example: Check if the owner has reached their API key limit.
|
|
374
431
|
# def reached_api_key_limit?
|
|
375
432
|
# limit = self.class.api_keys_settings[:max_keys]
|