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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +402 -13
- data/SECURITY.md +7 -6
- data/app/controllers/api_keys/keys_controller.rb +73 -46
- data/app/views/api_keys/keys/_form.html.erb +56 -10
- data/app/views/api_keys/keys/_key_row.html.erb +4 -2
- data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
- data/app/views/api_keys/keys/_show_token.html.erb +4 -7
- data/app/views/api_keys/keys/edit.html.erb +2 -0
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
- data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
- data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
- data/lib/api_keys/authentication.rb +174 -7
- data/lib/api_keys/configuration.rb +82 -0
- data/lib/api_keys/device_flow.rb +146 -0
- data/lib/api_keys/engine.rb +9 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +3 -1
- data/lib/api_keys/helpers/token_session.rb +183 -23
- data/lib/api_keys/helpers/view_helpers.rb +34 -0
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +261 -3
- data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
- data/lib/api_keys/models/device_authorization.rb +333 -0
- data/lib/api_keys/models/idempotency_key.rb +184 -0
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +13 -0
- data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
- data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
- data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
- data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
- data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
- data/lib/generators/api_keys/templates/initializer.rb +35 -0
- 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.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# the
|
|
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
|
-
|
|
203
|
+
status = consume_handoff(decoded_payload)
|
|
127
204
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
200
|
-
# consumer of a handoff;
|
|
201
|
-
#
|
|
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
|
|
344
|
+
return :consumed unless cache
|
|
205
345
|
|
|
206
346
|
marker = consumption_marker_key(decoded_payload)
|
|
207
|
-
return
|
|
347
|
+
return :consumed unless marker
|
|
348
|
+
return :consumed if cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL)
|
|
208
349
|
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|