api_keys 0.5.1 → 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 +37 -0
- data/README.md +126 -12
- 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 +123 -7
- data/lib/api_keys/configuration.rb +26 -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 +33 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +231 -0
- 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/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 +17 -0
- metadata +7 -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,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
|
|
@@ -15,6 +15,21 @@ module ApiKeys
|
|
|
15
15
|
MAX_RESTRICTION_ENTRIES = 100
|
|
16
16
|
MAX_RESTRICTION_ENTRY_BYTESIZE = 255
|
|
17
17
|
RESTRICTIONS_COLUMN = "restrictions"
|
|
18
|
+
# Refused-but-identified traffic (0.6.0). Kept apart from last_used_at and
|
|
19
|
+
# requests_count, which only ever describe successful authentications.
|
|
20
|
+
REFUSAL_STATS_COLUMNS = %w[last_refused_at refusals_count last_refusal_code].freeze
|
|
21
|
+
# Durable one-time token handoff (0.6.0): stamped by the first retrieval
|
|
22
|
+
# of the key's token through ApiKeys::TokenSession, and never cleared.
|
|
23
|
+
TOKEN_SHOWN_AT_COLUMN = "token_shown_at"
|
|
24
|
+
# Every refusal that happens after the presented token was verified, so the
|
|
25
|
+
# key is known: lifecycle, type/environment configuration, isolation,
|
|
26
|
+
# request restrictions, and scopes. Lookup failures (missing or invalid
|
|
27
|
+
# token, insecure transport) name no key and are never stamped on one.
|
|
28
|
+
REFUSAL_CODES = %w[
|
|
29
|
+
revoked_key expired_key unknown_key_type unknown_environment
|
|
30
|
+
environment_misconfigured environment_mismatch
|
|
31
|
+
origin_not_allowed ip_not_allowed restriction_misconfigured missing_scope
|
|
32
|
+
].freeze
|
|
18
33
|
# Deliberately excludes `restrictions`: owners must be able to tighten a
|
|
19
34
|
# request policy even when the key itself is non-revocable.
|
|
20
35
|
IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
|
|
@@ -32,6 +47,11 @@ module ApiKeys
|
|
|
32
47
|
# == Attributes & Serialization ==
|
|
33
48
|
# Expose the plaintext token only immediately after creation
|
|
34
49
|
attr_reader :token
|
|
50
|
+
|
|
51
|
+
# Written only by the atomic claim in `claim_token_handoff!`. Ordinary
|
|
52
|
+
# saves can never set or clear it, so no update path (a form, mass
|
|
53
|
+
# assignment, a stray `update`) can reopen a handoff that was claimed.
|
|
54
|
+
attr_readonly :token_shown_at
|
|
35
55
|
attr_accessor :expires_at_preset
|
|
36
56
|
|
|
37
57
|
# JSON attributes (:scopes, :metadata) are defined in the engine initializer
|
|
@@ -356,11 +376,23 @@ module ApiKeys
|
|
|
356
376
|
# - Key types mode: blank scopes means "no permissions". When you've configured
|
|
357
377
|
# key types with permission ceilings, an empty scope list should deny access,
|
|
358
378
|
# not silently bypass the entire permission system.
|
|
379
|
+
#
|
|
380
|
+
# Key types configured with `scopes: :ceiling` skip the stored list: the
|
|
381
|
+
# type's live permission ceiling is the contract, so a widened ceiling
|
|
382
|
+
# reaches existing keys and a narrowed one takes effect on the next request.
|
|
359
383
|
def allows_scope?(required_scope)
|
|
360
384
|
return false unless respond_to?(:scopes)
|
|
361
385
|
return false unless required_scope.present?
|
|
362
386
|
return false unless scopes.is_a?(Array)
|
|
363
387
|
|
|
388
|
+
if scopes_follow_ceiling?
|
|
389
|
+
# Stored scopes are metadata here, but a damaged list still means
|
|
390
|
+
# something wrote around validation: refuse rather than guess.
|
|
391
|
+
return false unless scopes.all? { |scope| valid_scope_value?(scope) }
|
|
392
|
+
|
|
393
|
+
return followed_ceiling.include?(required_scope.to_s)
|
|
394
|
+
end
|
|
395
|
+
|
|
364
396
|
if scopes.blank?
|
|
365
397
|
return !scope_policy_enabled?
|
|
366
398
|
end
|
|
@@ -401,6 +433,50 @@ module ApiKeys
|
|
|
401
433
|
ceiling == :all || ceiling.include?(scope.to_s)
|
|
402
434
|
end
|
|
403
435
|
|
|
436
|
+
# The scopes to save after a form that offered only some scopes as
|
|
437
|
+
# checkboxes was submitted: the rule the dashboard's edit form uses, for
|
|
438
|
+
# host forms built with `api_key_scopes_checkboxes`.
|
|
439
|
+
#
|
|
440
|
+
# - Offered scopes are set from the boxes: checked ones are granted,
|
|
441
|
+
# unchecked ones removed.
|
|
442
|
+
# - Every other scope the key holds is kept, in its current order.
|
|
443
|
+
# - A checked value that was never offered is ignored, so a crafted
|
|
444
|
+
# request cannot grant it.
|
|
445
|
+
# - A held scope outside the key's current ceiling is dropped, offered or
|
|
446
|
+
# not. Authentication already denies it, and keeping it would make every
|
|
447
|
+
# save of the key fail the ceiling validation. A newly checked scope
|
|
448
|
+
# outside the ceiling is kept, so the validation reports it.
|
|
449
|
+
# - `checked: nil` means the form submitted no scopes at all (no boxes and
|
|
450
|
+
# no sentinel): nothing is granted or removed except held scopes outside
|
|
451
|
+
# the ceiling.
|
|
452
|
+
# - A `scopes: :ceiling` key returns its stored scopes unchanged: they are
|
|
453
|
+
# metadata there, and the type's live ceiling authorizes the key.
|
|
454
|
+
#
|
|
455
|
+
# It only computes the list; assign it (`key.update(scopes: ...)`) to save.
|
|
456
|
+
#
|
|
457
|
+
# @param offered [Array<String, Symbol>] The scopes the form rendered as boxes.
|
|
458
|
+
# @param checked [Array<String, Symbol>, nil] The submitted values; blank
|
|
459
|
+
# entries (the sentinel) are ignored.
|
|
460
|
+
# @return [Array<String>]
|
|
461
|
+
# @raise [ArgumentError] when +offered+ or +checked+ is not an Array (or nil for +checked+).
|
|
462
|
+
def scopes_after_form_edit(offered:, checked:)
|
|
463
|
+
raise ArgumentError, "offered must be an Array of scope names" unless offered.is_a?(Array)
|
|
464
|
+
unless checked.nil? || checked.is_a?(Array)
|
|
465
|
+
raise ArgumentError, "checked must be an Array of scope names, or nil when no scopes were submitted"
|
|
466
|
+
end
|
|
467
|
+
return scopes.deep_dup if scopes_follow_ceiling?
|
|
468
|
+
|
|
469
|
+
offered = checked.nil? ? [] : offered.map(&:to_s)
|
|
470
|
+
checked = Array(checked).reject { |scope| scope.respond_to?(:blank?) && scope.blank? }.map(&:to_s)
|
|
471
|
+
held = Array(scopes)
|
|
472
|
+
|
|
473
|
+
kept = held.select do |scope|
|
|
474
|
+
scope_within_ceiling?(scope) && (!offered.include?(scope) || checked.include?(scope))
|
|
475
|
+
end
|
|
476
|
+
added = offered.select { |scope| checked.include?(scope) && !held.include?(scope) }
|
|
477
|
+
kept + added
|
|
478
|
+
end
|
|
479
|
+
|
|
404
480
|
# Alias for scopes - provides a more user-friendly API that matches
|
|
405
481
|
# the configuration DSL where key types use `permissions` for scope ceiling.
|
|
406
482
|
# Note: We use a method instead of alias_method because `scopes` is defined
|
|
@@ -417,6 +493,52 @@ module ApiKeys
|
|
|
417
493
|
allows_scope?(required_permission)
|
|
418
494
|
end
|
|
419
495
|
|
|
496
|
+
# Whether scope checks for this key read its type's live permission ceiling
|
|
497
|
+
# instead of the scopes stored at creation (`scopes: :ceiling` key types).
|
|
498
|
+
# Untyped keys, keys whose type is no longer configured, and every other
|
|
499
|
+
# type answer false and keep the stored-scope behavior.
|
|
500
|
+
# @return [Boolean]
|
|
501
|
+
def scopes_follow_ceiling?
|
|
502
|
+
return false if key_type.blank?
|
|
503
|
+
|
|
504
|
+
self.class.scopes_follow_ceiling_for?(key_type_config)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# == Refused-but-identified traffic ==
|
|
508
|
+
# Stamped asynchronously when a request presents this key's real token and a
|
|
509
|
+
# policy refuses it. Installations that have not run
|
|
510
|
+
# `rails generate api_keys:add_key_activity` read nil / 0 instead of raising.
|
|
511
|
+
|
|
512
|
+
# @return [Time, nil] When the most recently recorded refusal happened.
|
|
513
|
+
def last_refused_at
|
|
514
|
+
self.class.refusal_stats_columns? ? self[:last_refused_at] : nil
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# @return [Integer] Refusals counted while `track_requests_count` is on.
|
|
518
|
+
def refusals_count
|
|
519
|
+
self.class.refusal_stats_columns? ? self[:refusals_count].to_i : 0
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @return [String, nil] The error code of the most recently recorded refusal.
|
|
523
|
+
def last_refusal_code
|
|
524
|
+
self.class.refusal_stats_columns? ? self[:last_refusal_code] : nil
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# == One-time token handoff ==
|
|
528
|
+
|
|
529
|
+
# When this key's token was released through the one-time handoff
|
|
530
|
+
# (ApiKeys::TokenSession). Nil when it never was, and on installations that
|
|
531
|
+
# have not run `rails generate api_keys:add_key_activity` yet.
|
|
532
|
+
# @return [Time, nil]
|
|
533
|
+
def token_shown_at
|
|
534
|
+
self.class.token_shown_at_column? ? self[:token_shown_at] : nil
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# @return [Boolean] Whether the one-time handoff already released this key's token.
|
|
538
|
+
def token_shown?
|
|
539
|
+
!token_shown_at.nil?
|
|
540
|
+
end
|
|
541
|
+
|
|
420
542
|
# Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
|
|
421
543
|
# Requires the plaintext token to be available (only right after creation).
|
|
422
544
|
def masked_token
|
|
@@ -442,6 +564,105 @@ module ApiKeys
|
|
|
442
564
|
false
|
|
443
565
|
end
|
|
444
566
|
|
|
567
|
+
# Whether the refused-traffic columns exist. Installations that predate
|
|
568
|
+
# v0.6.0 keep authenticating untouched until they run the generator.
|
|
569
|
+
# @return [Boolean]
|
|
570
|
+
def self.refusal_stats_columns?
|
|
571
|
+
(REFUSAL_STATS_COLUMNS - column_names).empty?
|
|
572
|
+
rescue StandardError
|
|
573
|
+
false
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Whether the `token_shown_at` column exists. Without it the one-time
|
|
577
|
+
# handoff falls back to the cache marker alone, as in 0.5.1.
|
|
578
|
+
# @return [Boolean]
|
|
579
|
+
def self.token_shown_at_column?
|
|
580
|
+
column_names.include?(TOKEN_SHOWN_AT_COLUMN)
|
|
581
|
+
rescue StandardError
|
|
582
|
+
false
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Durably claims the one-time token handoff of the key with +id+ in one
|
|
586
|
+
# conditional statement:
|
|
587
|
+
#
|
|
588
|
+
# UPDATE api_keys SET token_shown_at = now WHERE id = ? AND token_shown_at IS NULL
|
|
589
|
+
#
|
|
590
|
+
# The database applies it atomically, so exactly one caller ever gets true
|
|
591
|
+
# for a key: across processes, across copies of a session, and after the
|
|
592
|
+
# cache is flushed. Zero rows changed means the token was already shown or
|
|
593
|
+
# the key no longer exists, and answers false (fail closed).
|
|
594
|
+
#
|
|
595
|
+
# Answers nil when the column does not exist yet (nothing can be recorded
|
|
596
|
+
# durably). Database errors are raised, not rescued: the caller withholds
|
|
597
|
+
# the token.
|
|
598
|
+
#
|
|
599
|
+
# @param id [Integer, String] The key's primary key.
|
|
600
|
+
# @return [Boolean, nil]
|
|
601
|
+
def self.claim_token_handoff!(id)
|
|
602
|
+
return nil unless column_names.include?(TOKEN_SHOWN_AT_COLUMN)
|
|
603
|
+
return false if id.nil?
|
|
604
|
+
|
|
605
|
+
where(primary_key => id, TOKEN_SHOWN_AT_COLUMN => nil)
|
|
606
|
+
.update_all(TOKEN_SHOWN_AT_COLUMN => Time.current) == 1
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Read-only companion of `claim_token_handoff!`: whether a claim for +id+
|
|
610
|
+
# could still succeed. Nil without the column. Raises on database errors.
|
|
611
|
+
#
|
|
612
|
+
# @param id [Integer, String] The key's primary key.
|
|
613
|
+
# @return [Boolean, nil]
|
|
614
|
+
def self.token_handoff_claimable?(id)
|
|
615
|
+
return nil unless column_names.include?(TOKEN_SHOWN_AT_COLUMN)
|
|
616
|
+
return false if id.nil?
|
|
617
|
+
|
|
618
|
+
where(primary_key => id, TOKEN_SHOWN_AT_COLUMN => nil).exists?
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Single source of truth for `scopes: :ceiling`. The finite-ceiling check
|
|
622
|
+
# repeats the configuration validation on purpose: a ceiling of `:all` must
|
|
623
|
+
# never become the contract, however the configuration was produced.
|
|
624
|
+
def self.scopes_follow_ceiling_for?(type_config)
|
|
625
|
+
return false unless type_config.is_a?(Hash) && type_config[:scopes].to_s == "ceiling"
|
|
626
|
+
|
|
627
|
+
permissions = type_config[:permissions]
|
|
628
|
+
permissions.is_a?(Array) && permissions.any?
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Add-only merge of a key type's current permission ceiling into the stored
|
|
632
|
+
# scopes of every non-revoked key of that type: the backfill for types that
|
|
633
|
+
# keep stored scopes after their ceiling was widened. It only ever adds
|
|
634
|
+
# permissions the type already allows, and it re-adds any a user removed
|
|
635
|
+
# by hand, so run it only for types whose users do not curate scopes.
|
|
636
|
+
#
|
|
637
|
+
# @param key_type [Symbol, String] A configured type with a finite ceiling.
|
|
638
|
+
# @return [Integer] How many keys gained scopes.
|
|
639
|
+
def self.reconcile_scopes!(key_type:)
|
|
640
|
+
type_name, type_config = ApiKeys.configuration.key_types.find { |name, _settings| name.to_s == key_type.to_s }
|
|
641
|
+
raise ArgumentError, "Unknown key type '#{key_type}'" unless type_config
|
|
642
|
+
|
|
643
|
+
ceiling = type_config[:permissions]
|
|
644
|
+
unless ceiling.is_a?(Array) && ceiling.any?
|
|
645
|
+
raise ArgumentError, "Key type '#{key_type}' has no finite permission ceiling to reconcile"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
ceiling = ceiling.map(&:to_s)
|
|
649
|
+
where(key_type: type_name.to_s, revoked_at: nil).find_each.count do |api_key|
|
|
650
|
+
api_key.with_lock do
|
|
651
|
+
# with_lock reloads the row: a key revoked (or retyped) after it was
|
|
652
|
+
# selected is skipped, so a revoked key never gains scopes.
|
|
653
|
+
next false if api_key.revoked_at.present? || api_key.key_type.to_s != type_name.to_s
|
|
654
|
+
|
|
655
|
+
stored = api_key.scopes
|
|
656
|
+
next false unless stored.is_a?(Array) && stored.all? { |scope| api_key.send(:valid_scope_value?, scope) }
|
|
657
|
+
|
|
658
|
+
merged = stored | ceiling
|
|
659
|
+
next false if merged == stored || merged.length > MAX_SCOPES
|
|
660
|
+
|
|
661
|
+
api_key.update_columns(scopes: merged, updated_at: Time.current)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
445
666
|
# Single source of truth for a key type's request-restriction ceiling.
|
|
446
667
|
# Omitting the setting allows every supported restriction kind.
|
|
447
668
|
def self.restriction_kinds_for(type_config)
|
|
@@ -706,6 +927,11 @@ module ApiKeys
|
|
|
706
927
|
|
|
707
928
|
def scopes_respect_permission_ceiling
|
|
708
929
|
return unless scopes.is_a?(Array)
|
|
930
|
+
# On a `scopes: :ceiling` key the stored list authorizes nothing. Narrowing
|
|
931
|
+
# the ceiling must not leave every existing key of the type unsaveable
|
|
932
|
+
# (which would also block tightening its request restrictions), so only
|
|
933
|
+
# writes to the scopes themselves are held to the current ceiling.
|
|
934
|
+
return if persisted? && scopes_follow_ceiling? && !will_save_change_to_attribute?(:scopes)
|
|
709
935
|
|
|
710
936
|
ceiling = permission_ceiling
|
|
711
937
|
return if ceiling == :all
|
|
@@ -754,6 +980,11 @@ module ApiKeys
|
|
|
754
980
|
false
|
|
755
981
|
end
|
|
756
982
|
|
|
983
|
+
# The live ceiling a `scopes: :ceiling` key is authorized for.
|
|
984
|
+
def followed_ceiling
|
|
985
|
+
Array(key_type_config&.[](:permissions)).map(&:to_s)
|
|
986
|
+
end
|
|
987
|
+
|
|
757
988
|
def permission_ceiling
|
|
758
989
|
if key_type.present?
|
|
759
990
|
config = key_type_config
|
data/lib/api_keys/version.rb
CHANGED