api_keys 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +158 -9
  4. data/SECURITY.md +22 -2
  5. data/app/controllers/api_keys/keys_controller.rb +136 -14
  6. data/app/views/api_keys/keys/_form.html.erb +85 -17
  7. data/app/views/api_keys/keys/_key_row.html.erb +4 -2
  8. data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
  9. data/app/views/api_keys/keys/_restriction_fields.html.erb +4 -2
  10. data/app/views/api_keys/keys/_show_token.html.erb +4 -7
  11. data/app/views/api_keys/keys/edit.html.erb +2 -0
  12. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  13. data/app/views/layouts/api_keys/application.html.erb +6 -0
  14. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  15. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  16. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  17. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  18. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  19. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  20. data/lib/api_keys/authentication.rb +123 -7
  21. data/lib/api_keys/configuration.rb +37 -6
  22. data/lib/api_keys/form_builder_extensions.rb +42 -3
  23. data/lib/api_keys/helpers/token_session.rb +257 -8
  24. data/lib/api_keys/helpers/view_helpers.rb +33 -0
  25. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  26. data/lib/api_keys/models/api_key.rb +293 -7
  27. data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
  28. data/lib/api_keys/restrictions.rb +75 -24
  29. data/lib/api_keys/services/authenticator.rb +24 -2
  30. data/lib/api_keys/version.rb +1 -1
  31. data/lib/api_keys.rb +7 -0
  32. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  33. data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
  34. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  35. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +12 -0
  36. data/lib/generators/api_keys/templates/initializer.rb +32 -7
  37. metadata +10 -1
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/cache"
3
4
  require "active_support/core_ext/numeric/time"
4
5
  require "active_support/message_encryptor"
6
+ require "digest"
5
7
  require "json"
8
+ require "securerandom"
9
+ require_relative "../logging"
6
10
 
7
11
  module ApiKeys
8
12
  module Helpers
@@ -16,6 +20,40 @@ module ApiKeys
16
20
  # 2. On the success page, decrypt, retrieve, and clear the token
17
21
  # 3. If the user refreshes, the token is gone
18
22
  #
23
+ # Deleting the payload from the session only consumes it in that session.
24
+ # A copy of the session taken before retrieval (a copied CookieStore
25
+ # cookie, or two requests racing with the same cookie) would still carry
26
+ # it. Two more layers make retrieval one-time across copies:
27
+ #
28
+ # 1. Durable (0.6.0): the first retrieval of a key's handoff claims the
29
+ # key's `token_shown_at` column with one conditional UPDATE
30
+ # (`... WHERE id = ? AND token_shown_at IS NULL`). The database makes
31
+ # exactly one claim succeed, and a cache flush cannot undo it. Zero rows
32
+ # changed (already shown, or the key is gone) returns nil. Installations
33
+ # without the column skip this layer and behave as 0.5.1.
34
+ #
35
+ # The claim is only durable once it commits, so it must run outside any
36
+ # database transaction: the plaintext is returned before an enclosing
37
+ # transaction could roll the claim back. A retrieval attempted inside an
38
+ # open transaction on the ApiKey connection is therefore refused,
39
+ # whatever the cache: it withholds the token with `:inside_transaction`,
40
+ # records nothing, and leaves the handoff in the session, so the host
41
+ # can retrieve it again outside the transaction. The check is Active
42
+ # Record's transaction manager on that connection, so a transaction
43
+ # opened with raw SQL (`BEGIN`) is not detected. The dashboard opens no
44
+ # transaction itself; a host `around_action` that wraps its requests in
45
+ # one sees the refusal.
46
+ # 2. Cache marker (0.5.1): each handoff has a random ID, and retrieval
47
+ # records it in `Rails.cache` with `write(..., unless_exist: true)`;
48
+ # a later retrieval of the same handoff finds the marker and gets nil.
49
+ # With the column it is a second layer (it also covers handoffs that
50
+ # name no key); without it, it is the only one, and it needs a cache
51
+ # store every process shares, with an atomic `unless_exist:`, that
52
+ # keeps the marker for the handoff lifetime. With
53
+ # `ActiveSupport::Cache::NullStore` the marker cannot be recorded.
54
+ #
55
+ # Any failure in either layer withholds the token (fail closed).
56
+ #
19
57
  # @example In your controller
20
58
  # # After creating a key:
21
59
  # def create
@@ -31,13 +69,62 @@ module ApiKeys
31
69
  # end
32
70
  #
33
71
  class TokenSession
72
+ extend ApiKeys::Logging
73
+
34
74
  # Default session key for storing the token
35
75
  DEFAULT_SESSION_KEY = :api_keys_new_token
36
76
  MAX_TOKEN_BYTESIZE = 512
37
77
  MAX_CIPHERTEXT_BYTESIZE = 4096
38
78
  MAX_KEY_ID_BYTESIZE = 128
79
+ # The wire format is unchanged since 0.4.0, so processes on 0.5.0 and
80
+ # 0.5.1 can read each other's handoffs during a rolling deploy. 0.5.1
81
+ # adds a random handoff ID inside the encrypted payload.
39
82
  HANDOFF_VERSION = 2
40
83
  HANDOFF_TTL = 10.minutes
84
+ HANDOFF_ID_PATTERN = /\A[A-Za-z0-9_-]{22,64}\z/
85
+ CONSUMPTION_CACHE_NAMESPACE = "api_keys:v1:token_handoff_consumed"
86
+
87
+ # The outcome of `retrieve_once_with_status`. `token` is set only when
88
+ # `status` is `:retrieved`. The other statuses are:
89
+ # - `:already_shown`: an authentic handoff for this key whose token an
90
+ # earlier retrieval already released (a replayed copy of the session).
91
+ # - `:withheld`: the database or cache could not record the retrieval,
92
+ # so the token is withheld rather than risk a second release.
93
+ # - `:inside_transaction`: retrieval ran inside an open Active Record
94
+ # transaction on the ApiKey connection, where its one-time claim could
95
+ # be rolled back after the token was shown. The token is withheld,
96
+ # nothing is recorded, and the handoff stays in the session for a
97
+ # retry outside the transaction.
98
+ # - `:missing`: no usable handoff for this key (none stored, expired,
99
+ # tampered, or bound to another key).
100
+ #
101
+ # Inspection never prints the token.
102
+ Retrieval = Struct.new(:token, :status) do
103
+ def retrieved?
104
+ status == :retrieved
105
+ end
106
+
107
+ def already_shown?
108
+ status == :already_shown
109
+ end
110
+
111
+ # Struct's default inspection, string, pretty-print and JSON forms
112
+ # would all print the plaintext token; these say only whether there
113
+ # is one. Read it through #token.
114
+ def inspect
115
+ "#<#{self.class.name} status=#{status.inspect} token=#{token.nil? ? 'nil' : '[REDACTED]'}>"
116
+ end
117
+
118
+ alias_method :to_s, :inspect
119
+
120
+ def pretty_print(printer)
121
+ printer.text(inspect)
122
+ end
123
+
124
+ def as_json(*)
125
+ { "status" => status.to_s, "token" => (token.nil? ? nil : "[REDACTED]") }
126
+ end
127
+ end
41
128
  ENCRYPTION_CIPHER = "aes-256-gcm"
42
129
  ENCRYPTION_SALT = "api_keys/token_session/v2"
43
130
  ENCRYPTION_PURPOSE = "api_keys.token_session"
@@ -68,7 +155,7 @@ module ApiKeys
68
155
 
69
156
  api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
70
157
  encrypted_payload = token_encryptor.encrypt_and_sign(
71
- { "token" => token, "api_key_id" => api_key_id },
158
+ { "token" => token, "api_key_id" => api_key_id, "handoff_id" => SecureRandom.urlsafe_base64(24) },
72
159
  expires_in: HANDOFF_TTL,
73
160
  purpose: ENCRYPTION_PURPOSE
74
161
  )
@@ -81,27 +168,53 @@ module ApiKeys
81
168
  end
82
169
 
83
170
  # Retrieve and clear the token from the session.
84
- # Returns nil if no token is stored (e.g., page was refreshed).
171
+ # Returns nil if no token is stored (e.g., page was refreshed), or if
172
+ # this handoff was already retrieved through any copy of the session.
85
173
  #
86
174
  # @param session [ActionDispatch::Request::Session] The Rails session
87
175
  # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
88
176
  # @return [String, nil] The token, or nil if not present
89
177
  def retrieve_once(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
178
+ retrieve_once_with_status(session, key: key, api_key: api_key, api_key_id: api_key_id).token
179
+ end
180
+
181
+ # Same as `retrieve_once`, and also says why no token came back, so a
182
+ # page can tell a replayed handoff ("already shown") from a missing
183
+ # one. The token is consumed exactly as `retrieve_once` consumes it.
184
+ #
185
+ # @return [Retrieval]
186
+ def retrieve_once_with_status(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
90
187
  payload = session.delete(key)
91
188
  expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
92
189
 
93
190
  # Plain string payloads from older versions remain readable only when
94
191
  # the caller does not request ID binding.
95
- return payload if expected_id.nil? && valid_token_payload?(payload)
192
+ return Retrieval.new(payload, :retrieved).freeze if expected_id.nil? && valid_token_payload?(payload)
96
193
  decoded_payload = decode_payload(payload)
97
- return nil unless decoded_payload
194
+ return Retrieval.new(nil, :missing).freeze unless decoded_payload
98
195
 
99
196
  token = decoded_payload["token"] || decoded_payload[:token]
100
197
  stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
101
- return nil if expected_id && stored_id.to_s != expected_id.to_s
102
- return nil unless valid_token_payload?(token)
198
+ bound_elsewhere = expected_id && stored_id.to_s != expected_id.to_s
103
199
 
104
- token
200
+ # Any authentic retrieval attempt uses the handoff up, matching the
201
+ # session deletion above: a mismatched or replayed attempt never
202
+ # leaves the handoff available to a later one.
203
+ status = consume_handoff(decoded_payload)
204
+
205
+ # A handoff bound to another key says nothing about this one.
206
+ return Retrieval.new(nil, :missing).freeze if bound_elsewhere
207
+
208
+ if status == :inside_transaction
209
+ # Nothing was recorded, so the handoff goes back into the session
210
+ # and a retrieval outside the transaction can still release it.
211
+ session[key] = payload
212
+ return Retrieval.new(nil, :inside_transaction).freeze
213
+ end
214
+ return Retrieval.new(nil, status).freeze unless status == :consumed
215
+ return Retrieval.new(nil, :missing).freeze unless valid_token_payload?(token)
216
+
217
+ Retrieval.new(token, :retrieved).freeze
105
218
  end
106
219
 
107
220
  # Check if a token is available in the session without removing it.
@@ -121,8 +234,9 @@ module ApiKeys
121
234
  token = decoded_payload["token"] || decoded_payload[:token]
122
235
  stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
123
236
  return false if expected_id && stored_id.to_s != expected_id.to_s
237
+ return false unless valid_token_payload?(token)
124
238
 
125
- valid_token_payload?(token)
239
+ !handoff_consumed?(decoded_payload)
126
240
  end
127
241
 
128
242
  private
@@ -146,6 +260,11 @@ module ApiKeys
146
260
  inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
147
261
  return nil unless outer_id == inner_id
148
262
 
263
+ # Handoffs written before 0.5.1 carry no ID; a present ID must be
264
+ # well formed, since it names the consumption marker.
265
+ handoff_id = decoded["handoff_id"]
266
+ return nil unless handoff_id.nil? || (handoff_id.is_a?(String) && handoff_id.match?(HANDOFF_ID_PATTERN))
267
+
149
268
  decoded
150
269
  rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
151
270
  nil
@@ -161,6 +280,136 @@ module ApiKeys
161
280
  nil
162
281
  end
163
282
 
283
+ # Uses the handoff up, durably first and then in the cache. Answers
284
+ # :consumed only to the first consumer of a handoff; a later one gets
285
+ # :already_shown, and any failure :withheld (fail closed).
286
+ def consume_handoff(decoded_payload)
287
+ stored_id = decoded_payload["api_key_id"]
288
+ return refuse_inside_transaction(stored_id) if claim_could_roll_back?(stored_id)
289
+
290
+ durable = claim_durably(decoded_payload)
291
+ return durable unless durable.nil? || durable == :consumed
292
+
293
+ consume_cache_marker(decoded_payload)
294
+ rescue StandardError => error
295
+ log_warn "[ApiKeys Security] Token handoff could not be checked against the database (#{error.class}); withholding the token."
296
+ :withheld
297
+ end
298
+
299
+ # Whether this handoff has a durable claim that an open transaction on
300
+ # the api_keys connection could roll back after the token was shown.
301
+ # Raises on database errors (the caller withholds the token).
302
+ def claim_could_roll_back?(stored_id)
303
+ return false if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
304
+ return false unless ::ApiKeys::ApiKey.column_names.include?(::ApiKeys::ApiKey::TOKEN_SHOWN_AT_COLUMN)
305
+
306
+ ::ApiKeys::ApiKey.connection_pool.with_connection(&:transaction_open?)
307
+ end
308
+
309
+ # Inside a transaction the claim is not durable until the host commits,
310
+ # and the token would be returned before that. No cache can stand in
311
+ # for it: a process-local store protects one process, and after a
312
+ # rollback another process would release the token again. So the
313
+ # retrieval is refused and records nothing (no claim, no marker). Only
314
+ # a read tells an already-used handoff apart, which is not put back.
315
+ def refuse_inside_transaction(stored_id)
316
+ return :already_shown if ::ApiKeys::ApiKey.token_handoff_claimable?(stored_id) == false
317
+
318
+ log_warn "[ApiKeys Security] Token handoff retrieval ran inside an open database transaction, where its " \
319
+ "one-time claim could be rolled back; withholding the token and leaving the handoff in place. " \
320
+ "Retrieve it outside the transaction."
321
+ :inside_transaction
322
+ end
323
+
324
+ # The durable layer: the key's `token_shown_at`, claimed by one
325
+ # conditional UPDATE. nil when there is no durable layer for this
326
+ # handoff (the column does not exist yet, or the handoff names no key).
327
+ def claim_durably(decoded_payload)
328
+ stored_id = decoded_payload["api_key_id"]
329
+ return nil if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
330
+
331
+ case ::ApiKeys::ApiKey.claim_token_handoff!(stored_id)
332
+ when nil then nil
333
+ when true then :consumed
334
+ else :already_shown
335
+ end
336
+ rescue StandardError => error
337
+ log_warn "[ApiKeys Security] Token handoff could not be recorded in the database (#{error.class}); withholding the token."
338
+ :withheld
339
+ end
340
+
341
+ # The cache layer from 0.5.1: an atomic marker named by the handoff ID.
342
+ def consume_cache_marker(decoded_payload)
343
+ cache = consumption_cache
344
+ return :consumed unless cache
345
+
346
+ marker = consumption_marker_key(decoded_payload)
347
+ return :consumed unless marker
348
+ return :consumed if cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL)
349
+
350
+ # Refused: normally the marker is already there (a replay), but some
351
+ # stores also answer false on error, so only a marker that is
352
+ # really present counts as "already shown".
353
+ cache.exist?(marker) ? :already_shown : :withheld
354
+ rescue StandardError => error
355
+ log_warn "[ApiKeys Security] Token handoff consumption could not be recorded (#{error.class}); withholding the token."
356
+ :withheld
357
+ end
358
+
359
+ # Read-only: whether either layer says the handoff was used up. A
360
+ # failed check reads as consumed (fail closed).
361
+ def handoff_consumed?(decoded_payload)
362
+ durably_consumed?(decoded_payload) || cache_marker_present?(decoded_payload)
363
+ end
364
+
365
+ def durably_consumed?(decoded_payload)
366
+ stored_id = decoded_payload["api_key_id"]
367
+ return false if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
368
+
369
+ ::ApiKeys::ApiKey.token_handoff_claimable?(stored_id) == false
370
+ rescue StandardError => error
371
+ log_warn "[ApiKeys Security] Token handoff consumption could not be checked in the database (#{error.class})."
372
+ true
373
+ end
374
+
375
+ def cache_marker_present?(decoded_payload)
376
+ cache = consumption_cache
377
+ return false unless cache
378
+
379
+ marker = consumption_marker_key(decoded_payload)
380
+ return false unless marker
381
+
382
+ cache.exist?(marker)
383
+ rescue StandardError => error
384
+ log_warn "[ApiKeys Security] Token handoff consumption could not be checked (#{error.class})."
385
+ true
386
+ end
387
+
388
+ # The cache that can enforce one-time retrieval across copies of a
389
+ # session, or nil when there is none. NullStore accepts every write and
390
+ # remembers nothing, so it cannot enforce anything.
391
+ def consumption_cache
392
+ cache = Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
393
+ return nil if cache.nil? || cache.is_a?(ActiveSupport::Cache::NullStore)
394
+
395
+ cache
396
+ end
397
+
398
+ # Handoffs written by 0.5.1 are named by their random ID. Handoffs
399
+ # written before it have none; they are named by a domain-separated
400
+ # digest of their token, which the encryption authenticates and which
401
+ # is identical in every copy of the session. (Bare-string handoffs from
402
+ # before 0.4.0 are returned before decoding and have no marker.)
403
+ def consumption_marker_key(decoded_payload)
404
+ handoff_id = decoded_payload["handoff_id"]
405
+ return "#{CONSUMPTION_CACHE_NAMESPACE}:#{handoff_id}" if handoff_id
406
+
407
+ token = decoded_payload["token"]
408
+ return nil unless valid_token_payload?(token)
409
+
410
+ "#{CONSUMPTION_CACHE_NAMESPACE}:token-#{Digest::SHA256.hexdigest("api_keys token handoff\0#{token}")}"
411
+ end
412
+
164
413
  def normalize_api_key_id(value)
165
414
  return nil if value.nil?
166
415
 
@@ -19,6 +19,39 @@ module ApiKeys
19
19
  # end
20
20
  #
21
21
  module ViewHelpers
22
+ # Plain-language reasons for every code ApiKeys::ApiKey::REFUSAL_CODES can
23
+ # stamp on a key. Written to finish the sentence "...refused, because".
24
+ REFUSAL_REASONS = {
25
+ "revoked_key" => "the key has been revoked",
26
+ "expired_key" => "the key has expired",
27
+ "unknown_key_type" => "the key's type is no longer configured",
28
+ "unknown_environment" => "the key's environment is no longer configured",
29
+ "environment_misconfigured" => "the key's environment could not be verified",
30
+ "environment_mismatch" => "the key was used in the wrong environment",
31
+ "origin_not_allowed" => "the request came from a web origin the key does not allow",
32
+ "ip_not_allowed" => "the request came from an IP address the key does not allow",
33
+ "restriction_misconfigured" => "the key's stored request restrictions could not be verified",
34
+ "missing_scope" => "the key lacks a permission the endpoint requires"
35
+ }.freeze
36
+ UNKNOWN_REFUSAL_REASON = "the request was refused for an unrecognized reason"
37
+
38
+ # Explains the most recently recorded refusal of a key: a request that
39
+ # presented the key's real token and was refused by a policy. Never echoes
40
+ # the stored code itself, so a tampered value cannot reach the page.
41
+ #
42
+ # @param api_key [ApiKeys::ApiKey] The API key
43
+ # @return [String, nil] A reason, or nil when no refusal has been recorded
44
+ #
45
+ # @example
46
+ # "Last refused because #{api_key_refusal_reason(@key)}"
47
+ # # => "Last refused because the request came from a web origin the key does not allow"
48
+ #
49
+ def api_key_refusal_reason(api_key)
50
+ return nil if api_key.last_refused_at.nil?
51
+
52
+ REFUSAL_REASONS.fetch(api_key.last_refusal_code.to_s, UNKNOWN_REFUSAL_REASON)
53
+ end
54
+
22
55
  # Returns the status of an API key as a symbol.
23
56
  #
24
57
  # @param api_key [ApiKeys::ApiKey] The API key
@@ -9,6 +9,11 @@ module ApiKeys
9
9
  module Jobs
10
10
  # Background job to update API key usage statistics (last_used_at, requests_count).
11
11
  # Enqueued by the Authentication concern after a successful request.
12
+ #
13
+ # With `refusal_code:` it records refused-but-identified traffic instead
14
+ # (last_refused_at, last_refusal_code, refusals_count). The two halves never
15
+ # share a write: a refusal is not a use, so it can never move last_used_at
16
+ # or requests_count, whatever the code says.
12
17
  class UpdateStatsJob < ActiveJob::Base
13
18
  include ApiKeys::Logging # Include logging helpers
14
19
 
@@ -21,7 +26,9 @@ module ApiKeys
21
26
  #
22
27
  # @param api_key_id [Integer, String] The ID of the ApiKey to update.
23
28
  # @param timestamp [Time] The timestamp of the request (when it was authenticated).
24
- def perform(api_key_id, timestamp)
29
+ # @param refusal_code [String, nil] Present when the request was refused
30
+ # after the key was identified; one of ApiKeys::ApiKey::REFUSAL_CODES.
31
+ def perform(api_key_id, timestamp, refusal_code: nil)
25
32
  api_key = ApiKey.find_by(id: api_key_id)
26
33
 
27
34
  unless api_key
@@ -40,6 +47,13 @@ module ApiKeys
40
47
  return
41
48
  end
42
49
 
50
+ # Any refusal marker at all, even a blank or unrecognized one, takes the
51
+ # refusal branch: the usage branch runs only for a genuine success.
52
+ unless refusal_code.nil?
53
+ record_refusal(api_key, timestamp, refusal_code)
54
+ return
55
+ end
56
+
43
57
  log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id}"
44
58
 
45
59
  # Jobs may execute out of order. Update only when this event is newer so
@@ -68,6 +82,36 @@ module ApiKeys
68
82
  log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id} (#{error.class})."
69
83
  # Consider re-raising or using a dead-letter queue strategy depending on job system
70
84
  end
85
+
86
+ private
87
+
88
+ # Stamps a refusal. An installation that has not run
89
+ # `rails generate api_keys:add_key_activity` yet skips it quietly, so a
90
+ # deploy that ships code before the migration never fails these jobs.
91
+ def record_refusal(api_key, timestamp, refusal_code)
92
+ unless ApiKey.refusal_stats_columns?
93
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Refusal stats columns are missing; skipping. " \
94
+ "Run: rails generate api_keys:add_key_activity && rails db:migrate"
95
+ return
96
+ end
97
+
98
+ code = refusal_code.to_s
99
+ unless ApiKey::REFUSAL_CODES.include?(code)
100
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] Ignored an unrecognized refusal code for ApiKey ID: #{api_key.id}."
101
+ return
102
+ end
103
+
104
+ # Same ordering guard as last_used_at: an older refusal processed late
105
+ # cannot overwrite a newer one, and the code always matches its timestamp.
106
+ ApiKey
107
+ .where(id: api_key.id)
108
+ .where("last_refused_at IS NULL OR last_refused_at < ?", timestamp)
109
+ .update_all(last_refused_at: timestamp, last_refusal_code: code)
110
+
111
+ # Counted under the same switch as requests_count, which is also the only
112
+ # mode in which no refusal is debounced away, so the count is exact.
113
+ ApiKey.increment_counter(:refusals_count, api_key.id) if ApiKeys.configuration.track_requests_count
114
+ end
71
115
  end
72
116
  end
73
117
  end