api_keys 0.5.1 → 0.7.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +402 -13
  4. data/SECURITY.md +7 -6
  5. data/app/controllers/api_keys/keys_controller.rb +73 -46
  6. data/app/views/api_keys/keys/_form.html.erb +56 -10
  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/_show_token.html.erb +4 -7
  10. data/app/views/api_keys/keys/edit.html.erb +2 -0
  11. data/app/views/layouts/api_keys/application.html.erb +6 -0
  12. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  13. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  14. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  15. data/lib/api_keys/authentication.rb +174 -7
  16. data/lib/api_keys/configuration.rb +82 -0
  17. data/lib/api_keys/device_flow.rb +146 -0
  18. data/lib/api_keys/engine.rb +9 -0
  19. data/lib/api_keys/errors.rb +11 -0
  20. data/lib/api_keys/form_builder_extensions.rb +3 -1
  21. data/lib/api_keys/helpers/token_session.rb +183 -23
  22. data/lib/api_keys/helpers/view_helpers.rb +34 -0
  23. data/lib/api_keys/idempotency.rb +155 -0
  24. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  25. data/lib/api_keys/models/api_key.rb +261 -3
  26. data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
  27. data/lib/api_keys/models/device_authorization.rb +333 -0
  28. data/lib/api_keys/models/idempotency_key.rb +184 -0
  29. data/lib/api_keys/version.rb +1 -1
  30. data/lib/api_keys.rb +13 -0
  31. data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
  32. data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
  33. data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
  34. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  35. data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
  36. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  37. data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
  39. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
  40. data/lib/generators/api_keys/templates/initializer.rb +35 -0
  41. metadata +17 -1
@@ -23,14 +23,36 @@ module ApiKeys
23
23
  # Deleting the payload from the session only consumes it in that session.
24
24
  # A copy of the session taken before retrieval (a copied CookieStore
25
25
  # cookie, or two requests racing with the same cookie) would still carry
26
- # it. So each handoff also has a random ID, and the first retrieval
27
- # records an atomic consumption marker for that ID in `Rails.cache`
28
- # (`write(..., unless_exist: true)`). Any later retrieval of the same
29
- # handoff finds the marker and gets nil. This guarantee needs a cache store
30
- # that every application process shares and that implements
31
- # `unless_exist:` atomically. With `ActiveSupport::Cache::NullStore` the
32
- # marker cannot be recorded, so deleting the payload from the session is
33
- # the only protection, as it was before 0.5.1.
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).
34
56
  #
35
57
  # @example In your controller
36
58
  # # After creating a key:
@@ -61,6 +83,48 @@ module ApiKeys
61
83
  HANDOFF_TTL = 10.minutes
62
84
  HANDOFF_ID_PATTERN = /\A[A-Za-z0-9_-]{22,64}\z/
63
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
64
128
  ENCRYPTION_CIPHER = "aes-256-gcm"
65
129
  ENCRYPTION_SALT = "api_keys/token_session/v2"
66
130
  ENCRYPTION_PURPOSE = "api_keys.token_session"
@@ -111,26 +175,46 @@ module ApiKeys
111
175
  # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
112
176
  # @return [String, nil] The token, or nil if not present
113
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)
114
187
  payload = session.delete(key)
115
188
  expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
116
189
 
117
190
  # Plain string payloads from older versions remain readable only when
118
191
  # the caller does not request ID binding.
119
- 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)
120
193
  decoded_payload = decode_payload(payload)
121
- return nil unless decoded_payload
194
+ return Retrieval.new(nil, :missing).freeze unless decoded_payload
195
+
196
+ token = decoded_payload["token"] || decoded_payload[:token]
197
+ stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
198
+ bound_elsewhere = expected_id && stored_id.to_s != expected_id.to_s
122
199
 
123
200
  # Any authentic retrieval attempt uses the handoff up, matching the
124
201
  # session deletion above: a mismatched or replayed attempt never
125
202
  # leaves the handoff available to a later one.
126
- return nil unless consume_handoff(decoded_payload)
203
+ status = consume_handoff(decoded_payload)
127
204
 
128
- token = decoded_payload["token"] || decoded_payload[:token]
129
- stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
130
- return nil if expected_id && stored_id.to_s != expected_id.to_s
131
- return nil unless valid_token_payload?(token)
205
+ # A handoff bound to another key says nothing about this one.
206
+ return Retrieval.new(nil, :missing).freeze if bound_elsewhere
132
207
 
133
- token
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
134
218
  end
135
219
 
136
220
  # Check if a token is available in the session without removing it.
@@ -196,23 +280,99 @@ module ApiKeys
196
280
  nil
197
281
  end
198
282
 
199
- # Records the consumption marker. Answers true only to the first
200
- # consumer of a handoff; every later one, and any cache failure, is
201
- # treated as "no token" (fail closed).
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).
202
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)
203
343
  cache = consumption_cache
204
- return true unless cache
344
+ return :consumed unless cache
205
345
 
206
346
  marker = consumption_marker_key(decoded_payload)
207
- return true unless marker
347
+ return :consumed unless marker
348
+ return :consumed if cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL)
208
349
 
209
- cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL) ? true : false
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
210
354
  rescue StandardError => error
211
355
  log_warn "[ApiKeys Security] Token handoff consumption could not be recorded (#{error.class}); withholding the token."
212
- false
356
+ :withheld
213
357
  end
214
358
 
359
+ # Read-only: whether either layer says the handoff was used up. A
360
+ # failed check reads as consumed (fail closed).
215
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)
216
376
  cache = consumption_cache
217
377
  return false unless cache
218
378
 
@@ -19,6 +19,40 @@ 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
+ "untrusted_key" => "the person behind the key no longer stands behind it"
36
+ }.freeze
37
+ UNKNOWN_REFUSAL_REASON = "the request was refused for an unrecognized reason"
38
+
39
+ # Explains the most recently recorded refusal of a key: a request that
40
+ # presented the key's real token and was refused by a policy. Never echoes
41
+ # the stored code itself, so a tampered value cannot reach the page.
42
+ #
43
+ # @param api_key [ApiKeys::ApiKey] The API key
44
+ # @return [String, nil] A reason, or nil when no refusal has been recorded
45
+ #
46
+ # @example
47
+ # "Last refused because #{api_key_refusal_reason(@key)}"
48
+ # # => "Last refused because the request came from a web origin the key does not allow"
49
+ #
50
+ def api_key_refusal_reason(api_key)
51
+ return nil if api_key.last_refused_at.nil?
52
+
53
+ REFUSAL_REASONS.fetch(api_key.last_refusal_code.to_s, UNKNOWN_REFUSAL_REASON)
54
+ end
55
+
22
56
  # Returns the status of an API key as a symbol.
23
57
  #
24
58
  # @param api_key [ApiKeys::ApiKey] The API key
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "authentication"
5
+
6
+ module ApiKeys
7
+ # Writes that run once per Idempotency-Key, for an API controller that
8
+ # authenticates with `authenticate_api_key!`
9
+ # (draft-ietf-httpapi-idempotency-key-header, ApiKeys::IdempotencyKey):
10
+ #
11
+ # class Api::BaseController < ActionController::API
12
+ # include ApiKeys::Controller
13
+ # include ApiKeys::Idempotency
14
+ #
15
+ # before_action :authenticate_api_key!
16
+ # around_action :run_once_per_idempotency_key
17
+ #
18
+ # private
19
+ #
20
+ # # The JSON for a record: the first answer and every retry use it.
21
+ # def resource_json(record, **options)
22
+ # record.as_json(only: %i[id name])
23
+ # end
24
+ #
25
+ # # Whether this API key may read the record now. A retry answers only
26
+ # # records it may read: a record moved to another owner is not found.
27
+ # def idempotent_replay_allowed?(record)
28
+ # record.owner == current_api_owner
29
+ # end
30
+ # end
31
+ #
32
+ # class Api::ProjectsController < Api::BaseController
33
+ # def create
34
+ # render_resource current_api_owner.projects.create!(project_params), status: :created
35
+ # end
36
+ # end
37
+ #
38
+ # The around_action runs outside any transaction the app opens: the key's
39
+ # claim must commit on its own (ApiKeys::IdempotencyKey.hold refuses).
40
+ #
41
+ # Every request but GET and HEAD needs the header. A write answers with
42
+ # `render_resource`, so the key keeps the record it made or changed, and a
43
+ # retry with the same key and the same request answers that record as it
44
+ # is now, with `Idempotent-Replayed: true`. Refusals render through
45
+ # `render_unauthorized`, like the gem's other refusals:
46
+ #
47
+ # 400 idempotency_key_required no header, or longer than 255 characters
48
+ # 409 idempotency_key_in_use a request with this key still runs
49
+ # 422 idempotency_key_reused the key was used for another request
50
+ module Idempotency
51
+ extend ActiveSupport::Concern
52
+ include ApiKeys::Authentication
53
+
54
+ REPLAYED_HEADER = "Idempotent-Replayed"
55
+ # The methods a host defines: the gem cannot know how to render a record,
56
+ # or who may read it.
57
+ HOST_METHODS = %i[resource_json idempotent_replay_allowed?].freeze
58
+
59
+ private
60
+
61
+ # The around_action. A write and its answer commit together; a refused
62
+ # write (any status but 2xx) or an error commits nothing and leaves the
63
+ # key free for the next attempt.
64
+ def run_once_per_idempotency_key
65
+ return yield if reading_request?
66
+ raise ApiKeys::Error, "run authenticate_api_key! before run_once_per_idempotency_key" unless current_api_key
67
+
68
+ require_idempotency_host_methods!
69
+
70
+ key = request.headers[ApiKeys::IdempotencyKey::HEADER].to_s
71
+ if key.blank? || key.length > ApiKeys::IdempotencyKey::MAX_LENGTH
72
+ return render_unauthorized(
73
+ error_code: :idempotency_key_required, status: :bad_request,
74
+ message: "Send an Idempotency-Key header with every write: a unique text of " \
75
+ "1 to #{ApiKeys::IdempotencyKey::MAX_LENGTH} characters, the same when you retry."
76
+ )
77
+ end
78
+
79
+ ApiKeys::IdempotencyKey.hold(current_api_key, key) do |record|
80
+ next replay_idempotent_request(record) if record.answered?
81
+
82
+ record.update!(request_digest: idempotency_request_digest)
83
+ yield
84
+ response.successful? ? record.answer!(response.status, @api_keys_idempotent_resource) : raise(ActiveRecord::Rollback)
85
+ end
86
+ rescue ApiKeys::IdempotencyKey::Busy
87
+ render_unauthorized(error_code: :idempotency_key_in_use, status: :conflict,
88
+ message: "A request with this Idempotency-Key is still running. Retry in a moment.")
89
+ end
90
+
91
+ # A write's answer: the record it made or changed, as `resource_json`
92
+ # renders it. The key keeps the record, so a retry answers the same way.
93
+ # Options go to `resource_json` for this answer only: a secret shown
94
+ # once is never in a retry's answer.
95
+ def render_resource(record, status: :ok, **options)
96
+ @api_keys_idempotent_resource = record
97
+ render json: resource_json(record, **options), status: status
98
+ end
99
+
100
+ # The JSON for a record. Define it in the host controller.
101
+ def resource_json(_record, **_options)
102
+ raise NotImplementedError,
103
+ "#{self.class.name} must define resource_json(record, **options) for ApiKeys::Idempotency"
104
+ end
105
+
106
+ # Whether current_api_key may read +record+ now. Define it in the host
107
+ # controller. A retry runs no action, so this is the only check before a
108
+ # record's current state goes back to the client.
109
+ def idempotent_replay_allowed?(_record)
110
+ raise NotImplementedError,
111
+ "#{self.class.name} must define idempotent_replay_allowed?(record) for ApiKeys::Idempotency: " \
112
+ "whether current_api_key may read the record that a retry answers with"
113
+ end
114
+
115
+ # Both host methods are checked on the first write, not on the first
116
+ # retry, so a missing one shows in development and tests.
117
+ def require_idempotency_host_methods!
118
+ missing = HOST_METHODS.select { |name| method(name).owner == ApiKeys::Idempotency }
119
+ return if missing.empty?
120
+
121
+ raise NotImplementedError, "#{self.class.name} must define #{missing.join(' and ')} for ApiKeys::Idempotency: " \
122
+ "resource_json(record, **options) renders a record, and " \
123
+ "idempotent_replay_allowed?(record) answers whether current_api_key may read it"
124
+ end
125
+
126
+ # The answer to a retry: the record as it is now, never a stored body,
127
+ # so it is current and holds no secret. A record that no longer exists,
128
+ # or that this API key may no longer read, is not found
129
+ # (ActiveRecord::RecordNotFound, which Rails answers 404).
130
+ def replay_idempotent_request(record)
131
+ if record.request_digest != idempotency_request_digest
132
+ return render_unauthorized(error_code: :idempotency_key_reused, status: 422,
133
+ message: "This Idempotency-Key was used for another request. Use a new key.")
134
+ end
135
+
136
+ resource = record.resource
137
+ unless resource && idempotent_replay_allowed?(resource)
138
+ raise ActiveRecord::RecordNotFound, "the record of this Idempotency-Key is not found for this API key"
139
+ end
140
+
141
+ response.headers[REPLAYED_HEADER] = "true"
142
+ render json: resource_json(resource), status: record.response_status
143
+ end
144
+
145
+ # The request a key stands for: the method, the path, the query, and
146
+ # the body. The query and the body count apart, so a name that moves
147
+ # from one to the other makes another request.
148
+ def idempotency_request_digest
149
+ @api_keys_idempotency_request_digest ||= ApiKeys::IdempotencyKey.digest_of(
150
+ request.request_method, request.path,
151
+ { "query" => request.query_parameters, "body" => request.request_parameters }
152
+ )
153
+ end
154
+ end
155
+ end
@@ -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