api_keys 0.4.3 → 0.5.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 +22 -0
- data/README.md +154 -21
- data/app/controllers/api_keys/keys_controller.rb +39 -8
- data/app/views/api_keys/keys/_form.html.erb +45 -4
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +32 -0
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- data/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +49 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/models/api_key.rb +184 -10
- data/lib/api_keys/models/concerns/has_api_keys.rb +59 -10
- data/lib/api_keys/restrictions.rb +379 -0
- data/lib/api_keys/services/authenticator.rb +88 -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 +57 -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 +41 -6
- metadata +5 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "active_support/core_ext/numeric/time"
|
|
4
4
|
require "active_support/core_ext/string/inflections"
|
|
5
5
|
require "active_support/security_utils"
|
|
6
|
+
require_relative "restrictions"
|
|
6
7
|
|
|
7
8
|
module ApiKeys
|
|
8
9
|
# Defines the configuration options for the ApiKeys gem.
|
|
@@ -41,6 +42,19 @@ module ApiKeys
|
|
|
41
42
|
# Security
|
|
42
43
|
attr_reader :https_only_production, :https_strict_mode
|
|
43
44
|
|
|
45
|
+
# Request Restrictions
|
|
46
|
+
#
|
|
47
|
+
# @!attribute [rw] client_ip_resolver
|
|
48
|
+
# @return [#call] Callable receiving the request and returning the client
|
|
49
|
+
# IP address used to evaluate a key's `allowed_ips` list. The default
|
|
50
|
+
# honors Rails' trusted-proxy handling via `request.remote_ip`; behind a
|
|
51
|
+
# CDN, configure `config.action_dispatch.trusted_proxies` whenever
|
|
52
|
+
# possible. A resolver that trusts a vendor header is safe only when
|
|
53
|
+
# network ingress rejects requests that bypass that vendor.
|
|
54
|
+
# @example
|
|
55
|
+
# config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") }
|
|
56
|
+
attr_reader :client_ip_resolver
|
|
57
|
+
|
|
44
58
|
# Tenant Resolution
|
|
45
59
|
attr_reader :tenant_resolver
|
|
46
60
|
|
|
@@ -81,13 +95,18 @@ module ApiKeys
|
|
|
81
95
|
# - :permissions [Array<String>, :all] Scope ceiling for this type
|
|
82
96
|
# - :revocable [Boolean] Whether keys can be revoked (default: true)
|
|
83
97
|
# - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
|
|
84
|
-
# - :public [Boolean] If true
|
|
85
|
-
#
|
|
86
|
-
#
|
|
98
|
+
# - :public [Boolean] If true, store the plaintext token in metadata so it
|
|
99
|
+
# can be viewed again in the dashboard. Use ONLY for publishable keys
|
|
100
|
+
# designed to be embedded in distributed apps. Public types must have a
|
|
101
|
+
# finite, non-empty permissions list. (default: false)
|
|
102
|
+
# - :restrictions [Array<Symbol>] Which request-restriction kinds keys of this
|
|
103
|
+
# type may carry: any subset of [:origins, :ips]. Omitted means both are
|
|
104
|
+
# allowed; `[]` forbids restrictions entirely for this type.
|
|
87
105
|
# @example
|
|
88
106
|
# config.key_types = {
|
|
89
|
-
# publishable: { prefix: "pk", permissions: %w[read],
|
|
90
|
-
#
|
|
107
|
+
# publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1,
|
|
108
|
+
# restrictions: [:origins] },
|
|
109
|
+
# secret: { prefix: "sk", permissions: :all, restrictions: [:ips] }
|
|
91
110
|
# }
|
|
92
111
|
#
|
|
93
112
|
# @!attribute [rw] environments
|
|
@@ -240,6 +259,12 @@ module ApiKeys
|
|
|
240
259
|
@tenant_resolver = value
|
|
241
260
|
end
|
|
242
261
|
|
|
262
|
+
def client_ip_resolver=(value)
|
|
263
|
+
raise ArgumentError, "client_ip_resolver must be callable" unless value.respond_to?(:call)
|
|
264
|
+
|
|
265
|
+
@client_ip_resolver = value
|
|
266
|
+
end
|
|
267
|
+
|
|
243
268
|
def secure_compare_proc=(value)
|
|
244
269
|
raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call)
|
|
245
270
|
|
|
@@ -403,11 +428,10 @@ module ApiKeys
|
|
|
403
428
|
raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil"
|
|
404
429
|
end
|
|
405
430
|
|
|
431
|
+
validate_restriction_kinds!(name, type_config[:restrictions]) if type_config.key?(:restrictions)
|
|
432
|
+
|
|
406
433
|
next unless type_config[:public] == true
|
|
407
434
|
|
|
408
|
-
unless type_config[:revocable] == false
|
|
409
|
-
raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false"
|
|
410
|
-
end
|
|
411
435
|
unless permissions.is_a?(Array) && permissions.any?
|
|
412
436
|
raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list"
|
|
413
437
|
end
|
|
@@ -416,6 +440,18 @@ module ApiKeys
|
|
|
416
440
|
validate_key_type_prefixes!(key_types_hash)
|
|
417
441
|
end
|
|
418
442
|
|
|
443
|
+
# A key type may declare which request-restriction kinds its keys can carry.
|
|
444
|
+
# Omitting the setting allows every kind; `[]` forbids all of them.
|
|
445
|
+
def validate_restriction_kinds!(name, kinds)
|
|
446
|
+
valid = kinds.is_a?(Array) && kinds.all? do |kind|
|
|
447
|
+
(kind.is_a?(Symbol) || kind.is_a?(String)) && ApiKeys::Restrictions::KIND_NAMES.include?(kind.to_s)
|
|
448
|
+
end
|
|
449
|
+
return if valid
|
|
450
|
+
|
|
451
|
+
raise ArgumentError,
|
|
452
|
+
"Key type '#{name}' restrictions must be an Array containing any of: #{ApiKeys::Restrictions::KIND_NAMES.join(', ')}"
|
|
453
|
+
end
|
|
454
|
+
|
|
419
455
|
def validate_config_name!(value, label)
|
|
420
456
|
return if valid_config_name?(value)
|
|
421
457
|
|
|
@@ -555,6 +591,11 @@ module ApiKeys
|
|
|
555
591
|
@https_only_production = true # Warn if used over HTTP in production
|
|
556
592
|
@https_strict_mode = true # Fail closed if a production request is not HTTPS
|
|
557
593
|
|
|
594
|
+
# Request Restrictions
|
|
595
|
+
# Rails' remote_ip already honors config.action_dispatch.trusted_proxies,
|
|
596
|
+
# so the sensible default is simply to trust what Rails resolved.
|
|
597
|
+
@client_ip_resolver = ->(request) { request.remote_ip }
|
|
598
|
+
|
|
558
599
|
# Background Job Queues
|
|
559
600
|
@stats_job_queue = :default
|
|
560
601
|
@callbacks_job_queue = :default
|
data/lib/api_keys/engine.rb
CHANGED
|
@@ -39,6 +39,12 @@ module ApiKeys
|
|
|
39
39
|
ApiKeys::ApiKey.attribute :scopes, json_col_type, default: []
|
|
40
40
|
ApiKeys::ApiKey.attribute :metadata, json_col_type, default: {}
|
|
41
41
|
|
|
42
|
+
# Request restrictions arrived in 0.5.0. Installations that have not
|
|
43
|
+
# run `rails generate api_keys:add_restrictions` yet must not gain a
|
|
44
|
+
# virtual attribute that silently accepts writes it cannot persist.
|
|
45
|
+
if ApiKeys::ApiKey.restrictions_column?
|
|
46
|
+
ApiKeys::ApiKey.attribute :restrictions, json_col_type, default: {}
|
|
47
|
+
end
|
|
42
48
|
end
|
|
43
49
|
end
|
|
44
50
|
end
|
data/lib/api_keys/errors.rb
CHANGED
|
@@ -69,5 +69,16 @@ module ApiKeys
|
|
|
69
69
|
)
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
# Raised when request restrictions are used but the `restrictions` column is missing
|
|
74
|
+
class RestrictionsMigrationRequiredError < BaseError
|
|
75
|
+
def initialize(message = nil)
|
|
76
|
+
super(
|
|
77
|
+
message ||
|
|
78
|
+
"Request restrictions are configured but the `restrictions` database column is missing. " \
|
|
79
|
+
"Run: rails generate api_keys:add_restrictions && rails db:migrate"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
72
83
|
end
|
|
73
84
|
end
|
|
@@ -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,62 @@ 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; anything else is stored untouched so
|
|
68
|
+
# the validation, rather than a silent coercion, is what reports it.
|
|
69
|
+
def restrictions=(value)
|
|
70
|
+
ensure_restrictions_column!
|
|
71
|
+
|
|
72
|
+
normalized = if value.nil?
|
|
73
|
+
{}
|
|
74
|
+
elsif value.is_a?(Hash)
|
|
75
|
+
wrapped = ApiKeys::Restrictions.wrap(value)
|
|
76
|
+
wrapped.malformed? ? value : wrapped.to_h
|
|
77
|
+
elsif value.is_a?(ApiKeys::Restrictions)
|
|
78
|
+
value.malformed? ? { "__malformed__" => true } : value.to_h
|
|
79
|
+
else
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
super(normalized)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Array<String>] Allowed web origins (hosts and `*.host` wildcards).
|
|
86
|
+
def allowed_origins
|
|
87
|
+
restrictions.origins
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Array<String>] Allowed IP addresses and CIDR ranges.
|
|
91
|
+
def allowed_ips
|
|
92
|
+
restrictions.ips
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Accepts an array or a raw string ("example.com, *.example.com") and
|
|
96
|
+
# normalizes it, so host applications never need their own parser.
|
|
97
|
+
def allowed_origins=(value)
|
|
98
|
+
self.restrictions = restrictions.to_h.merge("origins" => ApiKeys::Restrictions.normalize_origins(value))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Accepts an array or a raw string ("203.0.113.7, 10.0.0.0/8").
|
|
102
|
+
def allowed_ips=(value)
|
|
103
|
+
self.restrictions = restrictions.to_h.merge("ips" => ApiKeys::Restrictions.normalize_ips(value))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Boolean] true when this key carries any request restriction.
|
|
107
|
+
def restricted?
|
|
108
|
+
restrictions.restricted?
|
|
109
|
+
end
|
|
110
|
+
|
|
48
111
|
# == Validations ==
|
|
49
112
|
validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
|
|
50
113
|
validates :prefix, presence: true, length: { maximum: 64 }
|
|
@@ -73,6 +136,8 @@ module ApiKeys
|
|
|
73
136
|
validate :token_digest_matches_algorithm
|
|
74
137
|
validate :token_identifiers_are_well_formed
|
|
75
138
|
validate :metadata_is_well_formed
|
|
139
|
+
validate :restrictions_are_well_formed
|
|
140
|
+
validate :restrictions_respect_key_type, if: -> { key_type.present? }
|
|
76
141
|
validate :authentication_identity_is_immutable, on: :update
|
|
77
142
|
|
|
78
143
|
# TODO: Add validation for scope string format
|
|
@@ -101,7 +166,15 @@ module ApiKeys
|
|
|
101
166
|
# .publishable returns only keys with key_type: "publishable"
|
|
102
167
|
# .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type)
|
|
103
168
|
scope :publishable, -> { where(key_type: "publishable") }
|
|
104
|
-
|
|
169
|
+
# SQL `!=` excludes NULL, so include pre-key-types rows explicitly. Those
|
|
170
|
+
# legacy credentials have always had secret-key capabilities.
|
|
171
|
+
scope :secret, -> {
|
|
172
|
+
where(key_type: nil).or(where.not(key_type: "publishable"))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Keys that carry request restrictions, and keys usable from anywhere.
|
|
176
|
+
scope :restricted, -> { where.not(restrictions: [nil, {}]) }
|
|
177
|
+
scope :unrestricted, -> { where(restrictions: [nil, {}]) }
|
|
105
178
|
|
|
106
179
|
# === Usage Analytics Scopes ===
|
|
107
180
|
# These scopes help admin dashboards analyze API key usage patterns.
|
|
@@ -201,9 +274,12 @@ module ApiKeys
|
|
|
201
274
|
# Keys with a key_type check the configuration
|
|
202
275
|
def revocable?
|
|
203
276
|
return true if key_type.blank?
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
277
|
+
self.class.revocable_for(key_type_config)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Non-revocable keys are permanent by design; other key types may expire.
|
|
281
|
+
def expirable?
|
|
282
|
+
revocable?
|
|
207
283
|
end
|
|
208
284
|
|
|
209
285
|
# Returns the configuration hash for this key's type
|
|
@@ -220,17 +296,17 @@ module ApiKeys
|
|
|
220
296
|
configured_pair&.last
|
|
221
297
|
end
|
|
222
298
|
|
|
223
|
-
# Returns true if this key type is configured as public
|
|
224
|
-
# Only these keys have their plaintext token stored
|
|
299
|
+
# Returns true if this key type is explicitly configured as public.
|
|
300
|
+
# Only these keys have their plaintext token stored for later viewing.
|
|
225
301
|
# This is used for publishable keys that are designed to be embedded in distributed apps.
|
|
226
302
|
def public_key_type?
|
|
227
303
|
return false if key_type.blank?
|
|
228
304
|
config = key_type_config
|
|
229
305
|
return false if config.nil?
|
|
230
|
-
config[:public] == true
|
|
306
|
+
config[:public] == true
|
|
231
307
|
end
|
|
232
308
|
|
|
233
|
-
# Returns the stored plaintext token for public
|
|
309
|
+
# Returns the stored plaintext token for public keys.
|
|
234
310
|
# Returns nil for all other key types (the token is only available at creation time).
|
|
235
311
|
# @return [String, nil] The full plaintext token, or nil if not stored
|
|
236
312
|
def viewable_token
|
|
@@ -306,8 +382,41 @@ module ApiKeys
|
|
|
306
382
|
# == Class Methods ==
|
|
307
383
|
# Most creation logic is handled by standard ActiveRecord methods + callbacks
|
|
308
384
|
|
|
385
|
+
# Whether the `restrictions` column exists. Installations that predate
|
|
386
|
+
# v0.5.0 keep working untouched until they run the generator.
|
|
387
|
+
# @return [Boolean]
|
|
388
|
+
def self.restrictions_column?
|
|
389
|
+
column_names.include?(RESTRICTIONS_COLUMN)
|
|
390
|
+
rescue StandardError
|
|
391
|
+
false
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Single source of truth for a key type's request-restriction ceiling.
|
|
395
|
+
# Omitting the setting allows every supported restriction kind.
|
|
396
|
+
def self.restriction_kinds_for(type_config)
|
|
397
|
+
return ApiKeys::Restrictions::KINDS.dup unless type_config.is_a?(Hash) && type_config.key?(:restrictions)
|
|
398
|
+
|
|
399
|
+
Array(type_config[:restrictions]).map(&:to_sym)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Single source of truth for lifecycle policy in model and dashboard code.
|
|
403
|
+
def self.revocable_for(type_config)
|
|
404
|
+
type_config.is_a?(Hash) && type_config.fetch(:revocable, true)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# @return [Array<Symbol>] Restriction kinds this key's type permits.
|
|
408
|
+
def allowed_restriction_kinds
|
|
409
|
+
self.class.restriction_kinds_for(key_type_config)
|
|
410
|
+
end
|
|
411
|
+
|
|
309
412
|
private
|
|
310
413
|
|
|
414
|
+
def ensure_restrictions_column!
|
|
415
|
+
return if self.class.restrictions_column?
|
|
416
|
+
|
|
417
|
+
raise ApiKeys::Errors::RestrictionsMigrationRequiredError
|
|
418
|
+
end
|
|
419
|
+
|
|
311
420
|
# Set defaults for attributes not handled by the `attribute` API in the engine.
|
|
312
421
|
def set_defaults
|
|
313
422
|
# NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
|
|
@@ -390,10 +499,11 @@ module ApiKeys
|
|
|
390
499
|
self.expires_at = ApiKeys.configuration.expire_after.from_now
|
|
391
500
|
end
|
|
392
501
|
|
|
393
|
-
# Store plaintext token in metadata for public
|
|
502
|
+
# Store plaintext token in metadata for explicitly public keys.
|
|
394
503
|
# This allows users to view the token again in the dashboard.
|
|
395
504
|
# SECURITY: Only do this for keys explicitly configured as public: true
|
|
396
|
-
#
|
|
505
|
+
# Public key types must have a finite permission ceiling, but may be
|
|
506
|
+
# revocable and expirable like any other credential.
|
|
397
507
|
if public_key_type?
|
|
398
508
|
self.metadata = (self.metadata || {}).merge("token" => @token)
|
|
399
509
|
end
|
|
@@ -467,6 +577,70 @@ module ApiKeys
|
|
|
467
577
|
errors.add(:metadata, "must contain valid JSON data")
|
|
468
578
|
end
|
|
469
579
|
|
|
580
|
+
# Restrictions are security policy: a malformed list must fail loudly at
|
|
581
|
+
# write time rather than quietly protecting nothing at authentication time.
|
|
582
|
+
def restrictions_are_well_formed
|
|
583
|
+
return unless self.class.restrictions_column?
|
|
584
|
+
|
|
585
|
+
raw = self[:restrictions]
|
|
586
|
+
return if raw.nil?
|
|
587
|
+
|
|
588
|
+
unless raw.is_a?(Hash)
|
|
589
|
+
errors.add(:restrictions, "must be an object")
|
|
590
|
+
return
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
errors.add(:restrictions, "must contain valid restriction data") if restrictions.malformed? && raw.keys.empty?
|
|
594
|
+
|
|
595
|
+
unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES
|
|
596
|
+
if unknown_kinds.any?
|
|
597
|
+
errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}")
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
validate_restriction_list(:origins) { |entry| ApiKeys::Restrictions.valid_origin_entry?(entry) }
|
|
601
|
+
validate_restriction_list(:ips) { |entry| ApiKeys::Restrictions.valid_ip_entry?(entry) }
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def validate_restriction_list(kind)
|
|
605
|
+
entries = restrictions.public_send(kind)
|
|
606
|
+
|
|
607
|
+
if entries.length > MAX_RESTRICTION_ENTRIES
|
|
608
|
+
errors.add(:restrictions, "#{kind} cannot contain more than #{MAX_RESTRICTION_ENTRIES} entries")
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
if entries.any? { |entry| !valid_restriction_entry_size?(entry) }
|
|
612
|
+
errors.add(:restrictions, "#{kind} entries cannot exceed #{MAX_RESTRICTION_ENTRY_BYTESIZE} bytes")
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
return if entries.all? { |entry| yield(entry) }
|
|
616
|
+
|
|
617
|
+
message = if kind == :origins
|
|
618
|
+
"origins must be bare hosts like example.com or *.example.com"
|
|
619
|
+
else
|
|
620
|
+
"ips must be valid IPv4/IPv6 addresses or CIDR ranges"
|
|
621
|
+
end
|
|
622
|
+
errors.add(:restrictions, message)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Non-string entries are reported by the shape check below, not here.
|
|
626
|
+
def valid_restriction_entry_size?(entry)
|
|
627
|
+
return true unless entry.is_a?(String)
|
|
628
|
+
return false unless entry.valid_encoding?
|
|
629
|
+
|
|
630
|
+
entry.bytesize <= MAX_RESTRICTION_ENTRY_BYTESIZE
|
|
631
|
+
rescue ArgumentError
|
|
632
|
+
false
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Key types may declare a ceiling on the restriction kinds their keys carry,
|
|
636
|
+
# mirroring the way `permissions:` caps scopes.
|
|
637
|
+
def restrictions_respect_key_type
|
|
638
|
+
exceeded = restrictions.kinds - allowed_restriction_kinds
|
|
639
|
+
return if exceeded.empty?
|
|
640
|
+
|
|
641
|
+
errors.add(:restrictions, "#{exceeded.sort.join(', ')} are not allowed for #{key_type} keys")
|
|
642
|
+
end
|
|
643
|
+
|
|
470
644
|
def authentication_identity_is_immutable
|
|
471
645
|
IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name|
|
|
472
646
|
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,30 @@ 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
|
+
attributes = ApiKeys::Restrictions.wrap(restrictions).to_h
|
|
390
|
+
attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil?
|
|
391
|
+
attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil?
|
|
392
|
+
ApiKeys::Restrictions.wrap(attributes).to_h
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# True when any configured key type declares a restriction ceiling.
|
|
396
|
+
def restriction_ceilings_configured?(config)
|
|
397
|
+
config.key_types.present? &&
|
|
398
|
+
config.key_types.any? { |_type, settings| settings.is_a?(Hash) && settings.key?(:restrictions) }
|
|
399
|
+
end
|
|
400
|
+
|
|
360
401
|
# Check that required columns exist for key_types feature
|
|
361
402
|
# Raises MigrationRequiredError if columns are missing
|
|
362
403
|
def check_required_columns!
|
|
@@ -370,6 +411,14 @@ module ApiKeys
|
|
|
370
411
|
end
|
|
371
412
|
end
|
|
372
413
|
|
|
414
|
+
# Check that the restrictions column exists before writing to it.
|
|
415
|
+
# Raises RestrictionsMigrationRequiredError naming the generator.
|
|
416
|
+
def check_restrictions_column!
|
|
417
|
+
return if ApiKeys::ApiKey.restrictions_column?
|
|
418
|
+
|
|
419
|
+
raise ApiKeys::Errors::RestrictionsMigrationRequiredError
|
|
420
|
+
end
|
|
421
|
+
|
|
373
422
|
# Example: Check if the owner has reached their API key limit.
|
|
374
423
|
# def reached_api_key_limit?
|
|
375
424
|
# limit = self.class.api_keys_settings[:max_keys]
|