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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +158 -9
- data/SECURITY.md +22 -2
- data/app/controllers/api_keys/keys_controller.rb +136 -14
- data/app/views/api_keys/keys/_form.html.erb +85 -17
- 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/_restriction_fields.html.erb +4 -2
- 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/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -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 +123 -7
- data/lib/api_keys/configuration.rb +37 -6
- data/lib/api_keys/form_builder_extensions.rb +42 -3
- data/lib/api_keys/helpers/token_session.rb +257 -8
- data/lib/api_keys/helpers/view_helpers.rb +33 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +293 -7
- data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
- data/lib/api_keys/restrictions.rb +75 -24
- data/lib/api_keys/services/authenticator.rb +24 -2
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +7 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
- 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_table.rb.erb +12 -0
- data/lib/generators/api_keys/templates/initializer.rb +32 -7
- 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
|
-
|
|
102
|
-
return nil unless valid_token_payload?(token)
|
|
198
|
+
bound_elsewhere = expected_id && stored_id.to_s != expected_id.to_s
|
|
103
199
|
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|