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.
@@ -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 AND revocable: false, store plaintext token in
85
- # metadata so it can be viewed again in dashboard. Use ONLY for publishable
86
- # keys that are designed to be embedded in distributed apps. (default: false)
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], revocable: false, public: true, limit: 1 },
90
- # secret: { prefix: "sk", permissions: :all }
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
@@ -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
@@ -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
- scope :secret, -> { where.not(key_type: "publishable") }
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
- config = key_type_config
205
- return false if config.nil?
206
- config.fetch(:revocable, true)
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 AND non-revocable.
224
- # Only these keys have their plaintext token stored in metadata for later viewing.
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 && config[:revocable] == false
306
+ config[:public] == true
231
307
  end
232
308
 
233
- # Returns the stored plaintext token for public, non-revocable keys.
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, non-revocable keys.
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
- # AND revocable: false (e.g., publishable keys for distributed apps).
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, key_type: nil, environment: 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]