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.
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "uri"
5
+
6
+ module ApiKeys
7
+ # Value object describing *where* an API key may be used from: a list of web
8
+ # origins (hosts, with optional `*.` subdomain wildcards) and a list of IP
9
+ # addresses or CIDR ranges.
10
+ #
11
+ # Restrictions are plain data stored in the `restrictions` JSON column:
12
+ #
13
+ # { "origins" => ["example.com", "*.example.com"],
14
+ # "ips" => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] }
15
+ #
16
+ # Matching semantics (normative):
17
+ #
18
+ # - Within a list: OR. Any entry that matches admits the request.
19
+ # - Across lists: AND. Every list that is present and non-empty must pass.
20
+ # - Empty (or absent) restrictions mean unrestricted. Presence is the toggle.
21
+ # - Every failure mode fails closed: a locked list plus an unreadable request
22
+ # context refuses the request.
23
+ #
24
+ # The object is immutable and has no Active Record dependency. Malformed
25
+ # persisted values are represented explicitly and deny authentication; model
26
+ # validations keep them out during ordinary writes.
27
+ class Restrictions
28
+ # The restriction kinds this gem understands. Anything else stored in the
29
+ # column is a validation error rather than a silently ignored key.
30
+ KINDS = %i[origins ips].freeze
31
+ KIND_NAMES = KINDS.map(&:to_s).freeze
32
+
33
+ # Entries are split on commas, whitespace, and newlines so that a single
34
+ # text field can hold a whole list ("example.com, *.example.com").
35
+ ENTRY_SEPARATOR = /[\s,;]+/
36
+
37
+ # A bare host, optionally prefixed with a `*.` subdomain wildcard.
38
+ # `*` alone is deliberately invalid: an empty list already means "anywhere".
39
+ DNS_LABEL = /[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/
40
+ ORIGIN_ENTRY_PATTERN = /\A(?:\*\.)?#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/
41
+
42
+ attr_reader :origins, :ips, :extras
43
+
44
+ class << self
45
+ # Coerces anything into a Restrictions instance. Never raises.
46
+ #
47
+ # @param value [Restrictions, Hash, nil, Object] The stored column value,
48
+ # a hash of lists, or an existing instance.
49
+ # @return [ApiKeys::Restrictions]
50
+ def wrap(value)
51
+ return value if value.is_a?(self)
52
+ return none if value.nil?
53
+ return new(origins: [], ips: [], extras: {}, malformed: true) unless value.is_a?(Hash)
54
+
55
+ known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) }
56
+ known = known.to_h { |key, entries| [key.to_s, entries] }
57
+
58
+ new(
59
+ origins: coerce_list(known["origins"]),
60
+ ips: coerce_list(known["ips"]),
61
+ extras: extras.to_h
62
+ )
63
+ rescue StandardError
64
+ # Stored policy is untrusted input. Preserve the core invariant even if
65
+ # an exotic object raises while being coerced: malformed never means
66
+ # unrestricted.
67
+ new(origins: [], ips: [], extras: {}, malformed: true)
68
+ end
69
+
70
+ # The shared empty instance: no origins, no IPs, no restrictions at all.
71
+ # @return [ApiKeys::Restrictions]
72
+ def none
73
+ @none ||= new(origins: [], ips: [], extras: {}).freeze
74
+ end
75
+
76
+ # Forgiving parser for the raw string a dashboard text field submits.
77
+ # Accepts full URLs, bare hosts, commas, newlines, and stray whitespace;
78
+ # returns bare lowercase hosts, de-duplicated, order preserved.
79
+ #
80
+ # normalize_origins("https://Shop.example/, *.app.example\n x")
81
+ # # => ["shop.example", "*.app.example", "x"]
82
+ #
83
+ # Non-string entries are preserved so validation can report malformed
84
+ # programmatic input instead of silently erasing a requested policy.
85
+ # @param value [String, Array, nil] Raw user input.
86
+ # @return [Array] Normalized origin entries.
87
+ def normalize_origins(value)
88
+ tokenize(value).map do |token|
89
+ next token unless token.is_a?(String)
90
+
91
+ origin_host(token) || token.strip.downcase
92
+ end.uniq
93
+ end
94
+
95
+ # Forgiving parser for IP/CIDR input. String entries are kept verbatim
96
+ # (lowercased) so validation, not the parser, reports malformed ranges.
97
+ # Non-string entries are likewise preserved for validation.
98
+ #
99
+ # @param value [String, Array, nil] Raw user input.
100
+ # @return [Array<String>] Normalized IP entries.
101
+ def normalize_ips(value)
102
+ tokenize(value).map { |entry| entry.is_a?(String) ? entry.downcase : entry }.uniq
103
+ end
104
+
105
+ # Extracts the host the browser claims the request came from: the Origin
106
+ # header when present, the Referer header otherwise. Returns nil when
107
+ # neither is present or parseable, which callers must treat as a refusal.
108
+ #
109
+ # @param request [ActionDispatch::Request, #headers, nil]
110
+ # @return [String, nil] Bare lowercase host.
111
+ def extract_origin_host(request)
112
+ headers = request.headers if request.respond_to?(:headers)
113
+ return nil unless headers.respond_to?(:[])
114
+
115
+ origin = headers["Origin"]
116
+ unless origin.nil? || (origin.is_a?(String) && origin.strip.empty?)
117
+ # Origin has precedence over Referer. A present-but-invalid Origin
118
+ # (including the browser's opaque `null` origin) must not be rescued
119
+ # by a friendlier Referer value.
120
+ return host_from_url(origin)
121
+ end
122
+
123
+ host_from_url(headers["Referer"])
124
+ rescue StandardError
125
+ # A hostile or exotic request object must never take an endpoint down;
126
+ # an unreadable origin is simply an origin that matches nothing.
127
+ nil
128
+ end
129
+
130
+ # Splits raw input into candidate entries without interpreting them.
131
+ # @api private
132
+ def tokenize(value)
133
+ entries = case value
134
+ when nil then []
135
+ when String then value.split(ENTRY_SEPARATOR)
136
+ when Array then value.flat_map { |entry| entry.is_a?(String) ? entry.split(ENTRY_SEPARATOR) : [entry] }
137
+ else [value]
138
+ end
139
+
140
+ entries.filter_map do |entry|
141
+ next entry unless entry.is_a?(String)
142
+
143
+ trimmed = entry.strip
144
+ trimmed unless trimmed.empty?
145
+ end
146
+ end
147
+
148
+ # Reduces a single user-supplied entry to a bare lowercase host.
149
+ # Full URLs give up their host; bare hosts keep everything before the
150
+ # first slash, colon, or question mark. Returns nil when nothing is left.
151
+ # @api private
152
+ def origin_host(entry)
153
+ candidate = entry.to_s.strip
154
+ return nil if candidate.empty?
155
+
156
+ if candidate.include?("//")
157
+ host = host_from_url(candidate)
158
+ return host
159
+ end
160
+
161
+ if (address = parse_ip(candidate)) && !candidate.include?("/")
162
+ return address.to_s.downcase
163
+ end
164
+
165
+ if candidate.start_with?("[")
166
+ host = host_from_url("http://#{candidate}")
167
+ return host if host
168
+ end
169
+
170
+ host = candidate.split(%r{[/?#]}).first.to_s
171
+ host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000").
172
+ host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals.
173
+ host = host.downcase
174
+ host.empty? ? nil : host
175
+ end
176
+
177
+ # Pulls the host out of a full URL, tolerating garbage.
178
+ # @api private
179
+ def host_from_url(value)
180
+ return nil unless value.is_a?(String)
181
+
182
+ trimmed = value.strip
183
+ return nil if trimmed.empty?
184
+
185
+ host = URI.parse(trimmed).host
186
+ return nil if host.nil? || host.empty?
187
+
188
+ host.delete_prefix("[").delete_suffix("]").downcase
189
+ rescue URI::Error, ArgumentError
190
+ nil
191
+ end
192
+
193
+ # Coerces one stored list into an array of entries, preserving anything
194
+ # that is not a string so validations can report it instead of the value
195
+ # disappearing silently.
196
+ # @api private
197
+ def coerce_list(value)
198
+ entries = case value
199
+ when nil then []
200
+ when String then value.split(ENTRY_SEPARATOR)
201
+ when Array then value
202
+ else [value]
203
+ end
204
+
205
+ entries.filter_map do |entry|
206
+ next entry unless entry.is_a?(String)
207
+
208
+ trimmed = entry.strip.downcase
209
+ trimmed unless trimmed.empty?
210
+ rescue ArgumentError
211
+ entry
212
+ end
213
+ end
214
+
215
+ # Whether a stored origin entry is shaped like a host or `*.host`.
216
+ # @api private
217
+ def valid_origin_entry?(entry)
218
+ return false unless entry.is_a?(String)
219
+ return true if !entry.include?("/") && parse_ip(entry)
220
+
221
+ entry.bytesize <= 253 && entry.match?(ORIGIN_ENTRY_PATTERN)
222
+ rescue ArgumentError
223
+ false
224
+ end
225
+
226
+ # Whether a stored IP entry is a single address or a CIDR range.
227
+ # @api private
228
+ def valid_ip_entry?(entry)
229
+ parse_ip(entry) ? true : false
230
+ end
231
+
232
+ # Parses an address or range with stdlib IPAddr. A bare address is a /32
233
+ # (or /128), so `IPAddr#include?` answers exact matches and range matches
234
+ # through a single code path.
235
+ # @api private
236
+ def parse_ip(value)
237
+ return nil unless value.is_a?(String)
238
+
239
+ trimmed = value.strip
240
+ return nil if trimmed.empty?
241
+
242
+ address = IPAddr.new(trimmed)
243
+ address.ipv6? && address.ipv4_mapped? ? address.native : address
244
+ rescue IPAddr::Error
245
+ nil
246
+ end
247
+ end
248
+
249
+ # @param origins [Array<String>] Already-coerced origin entries.
250
+ # @param ips [Array<String>] Already-coerced IP entries.
251
+ # @param extras [Hash] Unrecognized keys, preserved so validation sees them.
252
+ # @param malformed [Boolean] Whether coercion itself found an invalid shape.
253
+ def initialize(origins: [], ips: [], extras: {}, malformed: false)
254
+ @origins = deep_copy(origins, freeze_copy: true)
255
+ @ips = deep_copy(ips, freeze_copy: true)
256
+ @extras = deep_copy(extras, freeze_copy: true)
257
+ @malformed = malformed || @extras.any? ||
258
+ @origins.any? { |entry| !self.class.valid_origin_entry?(entry) } ||
259
+ @ips.any? { |entry| !self.class.valid_ip_entry?(entry) }
260
+ freeze
261
+ end
262
+
263
+ # Malformed data can only arrive through validation-bypassing writes or a
264
+ # damaged database. Authentication always denies it.
265
+ def malformed?
266
+ @malformed
267
+ end
268
+
269
+ # @return [Boolean] true when this key may be used from anywhere.
270
+ def unrestricted?
271
+ !malformed? && origins.empty? && ips.empty?
272
+ end
273
+
274
+ # @return [Boolean] true when at least one list is locked.
275
+ def restricted?
276
+ !unrestricted?
277
+ end
278
+
279
+ # @return [Array<Symbol>] The restriction kinds actually in use.
280
+ def kinds
281
+ KINDS.select { |kind| public_send(kind).any? }
282
+ end
283
+
284
+ # The storage shape: known lists that have entries, plus any unrecognized
285
+ # keys exactly as they were found.
286
+ # @return [Hash]
287
+ def to_h
288
+ hash = {}
289
+ hash["origins"] = deep_copy(origins) if origins.any?
290
+ hash["ips"] = deep_copy(ips) if ips.any?
291
+ hash.merge(deep_copy(extras))
292
+ end
293
+
294
+ alias as_json to_h
295
+
296
+ # Does this request context satisfy every locked list?
297
+ #
298
+ # @param origin_host [String, nil] Host from Origin/Referer.
299
+ # @param ip [String, nil] Client IP address.
300
+ # @return [Boolean]
301
+ def allows?(origin_host: nil, ip: nil)
302
+ !malformed? && origin_allowed?(origin_host) && ip_allowed?(ip)
303
+ end
304
+
305
+ # @param host [String, nil] Bare host to check.
306
+ # @return [Boolean] true when the origins list is empty or one entry matches.
307
+ # A locked list plus a nil/blank host refuses: fail closed.
308
+ def origin_allowed?(host)
309
+ return false if malformed?
310
+ return true if origins.empty?
311
+
312
+ candidate = host.to_s.strip.downcase
313
+ return false if candidate.empty?
314
+ candidate = self.class.parse_ip(candidate)&.to_s || candidate
315
+
316
+ origins.any? { |entry| origin_entry_matches?(entry, candidate) }
317
+ end
318
+
319
+ # @param ip [String, nil] Client IP address.
320
+ # @return [Boolean] true when the IP list is empty or one entry contains it.
321
+ # A locked list plus an unparseable address refuses: fail closed.
322
+ def ip_allowed?(ip)
323
+ return false if malformed?
324
+ return true if ips.empty?
325
+
326
+ address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s)
327
+ return false unless address
328
+
329
+ ips.any? { |entry| ip_entry_matches?(entry, address) }
330
+ end
331
+
332
+ def ==(other)
333
+ other.is_a?(self.class) && other.to_h == to_h
334
+ end
335
+ alias eql? ==
336
+
337
+ def hash
338
+ to_h.hash
339
+ end
340
+
341
+ def inspect
342
+ "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect} malformed=#{malformed?.inspect}>"
343
+ end
344
+
345
+ private
346
+
347
+ def deep_copy(value, freeze_copy: false)
348
+ copy = case value
349
+ when Hash
350
+ value.to_h do |key, entry|
351
+ [deep_copy(key, freeze_copy: freeze_copy), deep_copy(entry, freeze_copy: freeze_copy)]
352
+ end
353
+ when Array
354
+ value.map { |entry| deep_copy(entry, freeze_copy: freeze_copy) }
355
+ when String
356
+ value.dup
357
+ else
358
+ value
359
+ end
360
+ copy.freeze if freeze_copy
361
+ copy
362
+ end
363
+
364
+ # `*.example.com` matches any subdomain at any depth, but never the apex —
365
+ # Google's rule. List the apex separately when you want both.
366
+ def origin_entry_matches?(entry, host)
367
+ if entry.start_with?("*.")
368
+ suffix = entry.delete_prefix("*")
369
+ host.end_with?(suffix) && host.length > suffix.length
370
+ else
371
+ entry == host
372
+ end
373
+ end
374
+
375
+ def ip_entry_matches?(entry, address)
376
+ self.class.parse_ip(entry).include?(address)
377
+ end
378
+ end
379
+ end
@@ -5,6 +5,7 @@ require "active_support/core_ext/object/blank"
5
5
  require "digest"
6
6
  require_relative "../models/api_key"
7
7
  require_relative "../services/digestor"
8
+ require_relative "../restrictions"
8
9
  require_relative "../logging"
9
10
 
10
11
  module ApiKeys
@@ -25,8 +26,13 @@ module ApiKeys
25
26
  new(success?: true, api_key: api_key)
26
27
  end
27
28
 
28
- def self.failure(error_code:, message:)
29
- new(success?: false, error_code: error_code, message: message)
29
+ # `api_key` is present when the key WAS identified and a policy check
30
+ # refused it (environment isolation, request restrictions): the
31
+ # after_authentication callback then reports WHICH key was refused,
32
+ # exactly as it already does for scope refusals. Lookup failures have
33
+ # no key to name, so they leave it nil.
34
+ def self.failure(error_code:, message:, api_key: nil)
35
+ new(success?: false, error_code: error_code, message: message, api_key: api_key)
30
36
  end
31
37
 
32
38
  # Do not delegate to Struct's default inspection: it recursively inspects
@@ -82,20 +88,25 @@ module ApiKeys
82
88
  elsif api_key&.active?
83
89
  log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
84
90
 
85
- # Check environment isolation if enabled
91
+ # Check environment isolation, then the key's request restrictions.
92
+ # Both run on every request, including token-cache hits: the
93
+ # cache only shortcuts the lookup, and the row is reloaded fresh.
86
94
  env_check_result = check_environment_isolation(api_key, config)
95
+ restriction_result = env_check_result ? nil : check_request_restrictions(api_key, request, config)
87
96
  if env_check_result
88
97
  env_check_result # Return failure result
98
+ elsif restriction_result
99
+ restriction_result # Return failure result
89
100
  else
90
101
  # TODO: Optionally update last_used_at and requests_count
91
102
  Result.success(api_key)
92
103
  end
93
104
  elsif api_key&.revoked?
94
105
  log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
95
- Result.failure(error_code: :revoked_key, message: "API key has been revoked")
106
+ Result.failure(error_code: :revoked_key, message: "API key has been revoked", api_key: api_key)
96
107
  elsif api_key&.expired?
97
108
  log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}"
98
- Result.failure(error_code: :expired_key, message: "API key has expired")
109
+ Result.failure(error_code: :expired_key, message: "API key has expired", api_key: api_key)
99
110
  else # Not found, mismatch, or inactive
100
111
  log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found."
101
112
  Result.failure(error_code: :invalid_token, message: "API token is invalid")
@@ -357,7 +368,7 @@ module ApiKeys
357
368
  return nil if configured
358
369
 
359
370
  log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its key type is not configured."
360
- Result.failure(error_code: :unknown_key_type, message: "API key type is not configured")
371
+ Result.failure(error_code: :unknown_key_type, message: "API key type is not configured", api_key: api_key)
361
372
  end
362
373
 
363
374
  def self.check_environment_configuration(api_key, config)
@@ -371,7 +382,7 @@ module ApiKeys
371
382
  return nil if configured
372
383
 
373
384
  log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its environment is not configured."
374
- Result.failure(error_code: :unknown_environment, message: "API key environment is not configured")
385
+ Result.failure(error_code: :unknown_environment, message: "API key environment is not configured", api_key: api_key)
375
386
  end
376
387
 
377
388
  # Check if the API key's environment matches the current environment
@@ -386,7 +397,8 @@ module ApiKeys
386
397
  if key_env.blank?
387
398
  return Result.failure(
388
399
  error_code: :environment_misconfigured,
389
- message: "API key environment could not be verified"
400
+ message: "API key environment could not be verified",
401
+ api_key: api_key
390
402
  )
391
403
  end
392
404
 
@@ -398,7 +410,8 @@ module ApiKeys
398
410
  log_warn "[ApiKeys Security] Current environment resolution failed (#{error.class})."
399
411
  return Result.failure(
400
412
  error_code: :environment_misconfigured,
401
- message: "API key environment could not be verified"
413
+ message: "API key environment could not be verified",
414
+ api_key: api_key
402
415
  )
403
416
  end
404
417
 
@@ -412,7 +425,8 @@ module ApiKeys
412
425
  log_warn "[ApiKeys Security] Strict environment isolation is enabled, but current_environment resolved to blank."
413
426
  return Result.failure(
414
427
  error_code: :environment_misconfigured,
415
- message: "API key environment could not be verified"
428
+ message: "API key environment could not be verified",
429
+ api_key: api_key
416
430
  )
417
431
  end
418
432
 
@@ -420,13 +434,74 @@ module ApiKeys
420
434
  log_debug "[ApiKeys Auth] Environment mismatch for key ID #{api_key.id}."
421
435
  return Result.failure(
422
436
  error_code: :environment_mismatch,
423
- message: "API key cannot be used in this environment"
437
+ message: "API key cannot be used in this environment",
438
+ api_key: api_key
424
439
  )
425
440
  end
426
441
 
427
442
  nil # Check passed
428
443
  end
429
444
 
445
+ # Enforces the key's request restrictions: which web origins and which IP
446
+ # addresses may present it. Empty lists mean unrestricted, so every key
447
+ # written before this feature existed passes untouched.
448
+ #
449
+ # Every list that is present must pass (AND across kinds); within a list
450
+ # any entry admits the request (OR within a kind). A locked list plus an
451
+ # unreadable request context refuses: these checks fail closed.
452
+ #
453
+ # @return [ApiKeys::Services::Authenticator::Result, nil] Failure, or nil when the check passes.
454
+ def self.check_request_restrictions(api_key, request, config)
455
+ restrictions = api_key.restrictions
456
+ return nil if restrictions.unrestricted?
457
+
458
+ if restrictions.malformed?
459
+ log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its stored request restrictions are malformed."
460
+ return Result.failure(
461
+ error_code: :restriction_misconfigured,
462
+ message: "This API key's request restrictions could not be verified",
463
+ api_key: api_key
464
+ )
465
+ end
466
+
467
+ if restrictions.origins.any?
468
+ origin_host = ApiKeys::Restrictions.extract_origin_host(request)
469
+ unless restrictions.origin_allowed?(origin_host)
470
+ log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed."
471
+ return Result.failure(
472
+ error_code: :origin_not_allowed,
473
+ message: "This API key is restricted to specific web origins, and this request's origin is not allowed",
474
+ api_key: api_key
475
+ )
476
+ end
477
+ end
478
+
479
+ if restrictions.ips.any?
480
+ unless restrictions.ip_allowed?(resolve_client_ip(request, config))
481
+ log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed."
482
+ return Result.failure(
483
+ error_code: :ip_not_allowed,
484
+ message: "This API key is restricted to specific IP addresses, and this request's address is not allowed",
485
+ api_key: api_key
486
+ )
487
+ end
488
+ end
489
+
490
+ nil # Check passed
491
+ end
492
+
493
+ # Resolves the client IP through the configured resolver. A resolver that
494
+ # blows up yields nil, which an IP-locked key treats as a refusal.
495
+ def self.resolve_client_ip(request, config)
496
+ resolver = config.client_ip_resolver
497
+ return nil unless resolver.respond_to?(:call)
498
+
499
+ resolver.call(request)
500
+ rescue StandardError => error
501
+ log_warn "[ApiKeys Security] Client IP resolution failed (#{error.class}); treating the address as unknown."
502
+ nil
503
+ end
504
+
430
505
  private_class_method :extract_token, :find_and_verify_key, :find_sha256_key,
431
506
  :find_bcrypt_key, :find_bcrypt_key_for_prefixes,
432
507
  :find_bcrypt_key_by_last4, :find_verified_bcrypt_candidate,
@@ -437,7 +512,8 @@ module ApiKeys
437
512
  :safe_find_by_id, :sanitize_prefixes, :valid_token?,
438
513
  :production_environment?, :secure_request?,
439
514
  :check_key_type_configuration, :check_environment_configuration,
440
- :check_environment_isolation
515
+ :check_environment_isolation, :check_request_restrictions,
516
+ :resolve_client_ip
441
517
  end
442
518
  end
443
519
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.4.3"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/api_keys.rb CHANGED
@@ -54,6 +54,7 @@ end
54
54
  require "api_keys/version"
55
55
  require "api_keys/configuration" # Defines the ApiKeys::Configuration class
56
56
  require "api_keys/errors" # Error classes for key types feature
57
+ require "api_keys/restrictions" # Origin/IP request restrictions value object
57
58
 
58
59
  # Files that might depend on ApiKeys.configuration being available
59
60
  require "api_keys/controller" # This can lead to loading jobs, etc.
@@ -35,7 +35,7 @@ module ApiKeys
35
35
  say " publishable: {"
36
36
  say " prefix: 'pk',"
37
37
  say " permissions: %w[read validate],"
38
- say " revocable: false,"
38
+ say " public: true,"
39
39
  say " limit: 1"
40
40
  say " },"
41
41
  say " secret: {"
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module ApiKeys
7
+ module Generators
8
+ # Rails generator for adding the `restrictions` column to the api_keys table.
9
+ # This generator is for existing installations that want to lock keys to
10
+ # specific web origins or IP addresses. New installs get the column from the
11
+ # install generator, so they never need to run this.
12
+ class AddRestrictionsGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ # Implement the required interface for Rails::Generators::Migration.
18
+ def self.next_migration_number(dirname)
19
+ next_migration_number = current_migration_number(dirname) + 1
20
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
21
+ end
22
+
23
+ # Creates the migration file using the template.
24
+ def create_migration_file
25
+ migration_template "add_restrictions_to_api_keys.rb.erb",
26
+ File.join(db_migrate_path, "add_restrictions_to_api_keys.rb")
27
+ end
28
+
29
+ # Displays helpful information to the user after installation.
30
+ def display_post_install_message
31
+ say "\n🌐 Request restrictions migration created!", :green
32
+ say "\nNext steps:"
33
+ say " 1. Run `rails db:migrate` to add the restrictions column."
34
+ say "\n 2. Lock a key to the places it may be used from:"
35
+ say " user.create_api_key!(name: 'Widget key', allowed_origins: 'example.com, *.example.com')"
36
+ say " key.allowed_ips = '203.0.113.7, 10.0.0.0/8'"
37
+ say "\n Keys without restrictions keep working from anywhere; presence is the toggle."
38
+ say "\n 3. Optionally cap which restriction kinds each key type may carry:"
39
+ say " config.key_types = {"
40
+ say " publishable: { prefix: 'pk', permissions: %w[read], public: true,"
41
+ say " restrictions: [:origins] },"
42
+ say " secret: { prefix: 'sk', permissions: :all, restrictions: [:ips] }"
43
+ say " }"
44
+ say "\n 4. Behind a CDN or proxy, make sure the client IP is truthful:"
45
+ say " config.action_dispatch.trusted_proxies = ..."
46
+ say " # Trust a vendor header only when ingress blocks requests that bypass that vendor."
47
+ say "\nSee the api_keys README for detailed usage and examples.", :cyan
48
+ end
49
+
50
+ private
51
+
52
+ def migration_version
53
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
54
+ end
55
+ end
56
+ end
57
+ end