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.
@@ -23,14 +23,36 @@ module ApiKeys
23
23
  # Deleting the payload from the session only consumes it in that session.
24
24
  # A copy of the session taken before retrieval (a copied CookieStore
25
25
  # cookie, or two requests racing with the same cookie) would still carry
26
- # it. So each handoff also has a random ID, and the first retrieval
27
- # records an atomic consumption marker for that ID in `Rails.cache`
28
- # (`write(..., unless_exist: true)`). Any later retrieval of the same
29
- # handoff finds the marker and gets nil. This guarantee needs a cache store
30
- # that every application process shares and that implements
31
- # `unless_exist:` atomically. With `ActiveSupport::Cache::NullStore` the
32
- # marker cannot be recorded, so deleting the payload from the session is
33
- # the only protection, as it was before 0.5.1.
26
+ # it. Two more layers make retrieval one-time across copies:
27
+ #
28
+ # 1. Durable (0.6.0): the first retrieval of a key's handoff claims the
29
+ # key's `token_shown_at` column with one conditional UPDATE
30
+ # (`... WHERE id = ? AND token_shown_at IS NULL`). The database makes
31
+ # exactly one claim succeed, and a cache flush cannot undo it. Zero rows
32
+ # changed (already shown, or the key is gone) returns nil. Installations
33
+ # without the column skip this layer and behave as 0.5.1.
34
+ #
35
+ # The claim is only durable once it commits, so it must run outside any
36
+ # database transaction: the plaintext is returned before an enclosing
37
+ # transaction could roll the claim back. A retrieval attempted inside an
38
+ # open transaction on the ApiKey connection is therefore refused,
39
+ # whatever the cache: it withholds the token with `:inside_transaction`,
40
+ # records nothing, and leaves the handoff in the session, so the host
41
+ # can retrieve it again outside the transaction. The check is Active
42
+ # Record's transaction manager on that connection, so a transaction
43
+ # opened with raw SQL (`BEGIN`) is not detected. The dashboard opens no
44
+ # transaction itself; a host `around_action` that wraps its requests in
45
+ # one sees the refusal.
46
+ # 2. Cache marker (0.5.1): each handoff has a random ID, and retrieval
47
+ # records it in `Rails.cache` with `write(..., unless_exist: true)`;
48
+ # a later retrieval of the same handoff finds the marker and gets nil.
49
+ # With the column it is a second layer (it also covers handoffs that
50
+ # name no key); without it, it is the only one, and it needs a cache
51
+ # store every process shares, with an atomic `unless_exist:`, that
52
+ # keeps the marker for the handoff lifetime. With
53
+ # `ActiveSupport::Cache::NullStore` the marker cannot be recorded.
54
+ #
55
+ # Any failure in either layer withholds the token (fail closed).
34
56
  #
35
57
  # @example In your controller
36
58
  # # After creating a key:
@@ -61,6 +83,48 @@ module ApiKeys
61
83
  HANDOFF_TTL = 10.minutes
62
84
  HANDOFF_ID_PATTERN = /\A[A-Za-z0-9_-]{22,64}\z/
63
85
  CONSUMPTION_CACHE_NAMESPACE = "api_keys:v1:token_handoff_consumed"
86
+
87
+ # The outcome of `retrieve_once_with_status`. `token` is set only when
88
+ # `status` is `:retrieved`. The other statuses are:
89
+ # - `:already_shown`: an authentic handoff for this key whose token an
90
+ # earlier retrieval already released (a replayed copy of the session).
91
+ # - `:withheld`: the database or cache could not record the retrieval,
92
+ # so the token is withheld rather than risk a second release.
93
+ # - `:inside_transaction`: retrieval ran inside an open Active Record
94
+ # transaction on the ApiKey connection, where its one-time claim could
95
+ # be rolled back after the token was shown. The token is withheld,
96
+ # nothing is recorded, and the handoff stays in the session for a
97
+ # retry outside the transaction.
98
+ # - `:missing`: no usable handoff for this key (none stored, expired,
99
+ # tampered, or bound to another key).
100
+ #
101
+ # Inspection never prints the token.
102
+ Retrieval = Struct.new(:token, :status) do
103
+ def retrieved?
104
+ status == :retrieved
105
+ end
106
+
107
+ def already_shown?
108
+ status == :already_shown
109
+ end
110
+
111
+ # Struct's default inspection, string, pretty-print and JSON forms
112
+ # would all print the plaintext token; these say only whether there
113
+ # is one. Read it through #token.
114
+ def inspect
115
+ "#<#{self.class.name} status=#{status.inspect} token=#{token.nil? ? 'nil' : '[REDACTED]'}>"
116
+ end
117
+
118
+ alias_method :to_s, :inspect
119
+
120
+ def pretty_print(printer)
121
+ printer.text(inspect)
122
+ end
123
+
124
+ def as_json(*)
125
+ { "status" => status.to_s, "token" => (token.nil? ? nil : "[REDACTED]") }
126
+ end
127
+ end
64
128
  ENCRYPTION_CIPHER = "aes-256-gcm"
65
129
  ENCRYPTION_SALT = "api_keys/token_session/v2"
66
130
  ENCRYPTION_PURPOSE = "api_keys.token_session"
@@ -111,26 +175,46 @@ module ApiKeys
111
175
  # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
112
176
  # @return [String, nil] The token, or nil if not present
113
177
  def retrieve_once(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
178
+ retrieve_once_with_status(session, key: key, api_key: api_key, api_key_id: api_key_id).token
179
+ end
180
+
181
+ # Same as `retrieve_once`, and also says why no token came back, so a
182
+ # page can tell a replayed handoff ("already shown") from a missing
183
+ # one. The token is consumed exactly as `retrieve_once` consumes it.
184
+ #
185
+ # @return [Retrieval]
186
+ def retrieve_once_with_status(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
114
187
  payload = session.delete(key)
115
188
  expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
116
189
 
117
190
  # Plain string payloads from older versions remain readable only when
118
191
  # the caller does not request ID binding.
119
- return payload if expected_id.nil? && valid_token_payload?(payload)
192
+ return Retrieval.new(payload, :retrieved).freeze if expected_id.nil? && valid_token_payload?(payload)
120
193
  decoded_payload = decode_payload(payload)
121
- return nil unless decoded_payload
194
+ return Retrieval.new(nil, :missing).freeze unless decoded_payload
195
+
196
+ token = decoded_payload["token"] || decoded_payload[:token]
197
+ stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
198
+ bound_elsewhere = expected_id && stored_id.to_s != expected_id.to_s
122
199
 
123
200
  # Any authentic retrieval attempt uses the handoff up, matching the
124
201
  # session deletion above: a mismatched or replayed attempt never
125
202
  # leaves the handoff available to a later one.
126
- return nil unless consume_handoff(decoded_payload)
203
+ status = consume_handoff(decoded_payload)
127
204
 
128
- token = decoded_payload["token"] || decoded_payload[:token]
129
- stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
130
- return nil if expected_id && stored_id.to_s != expected_id.to_s
131
- return nil unless valid_token_payload?(token)
205
+ # A handoff bound to another key says nothing about this one.
206
+ return Retrieval.new(nil, :missing).freeze if bound_elsewhere
132
207
 
133
- token
208
+ if status == :inside_transaction
209
+ # Nothing was recorded, so the handoff goes back into the session
210
+ # and a retrieval outside the transaction can still release it.
211
+ session[key] = payload
212
+ return Retrieval.new(nil, :inside_transaction).freeze
213
+ end
214
+ return Retrieval.new(nil, status).freeze unless status == :consumed
215
+ return Retrieval.new(nil, :missing).freeze unless valid_token_payload?(token)
216
+
217
+ Retrieval.new(token, :retrieved).freeze
134
218
  end
135
219
 
136
220
  # Check if a token is available in the session without removing it.
@@ -196,23 +280,99 @@ module ApiKeys
196
280
  nil
197
281
  end
198
282
 
199
- # Records the consumption marker. Answers true only to the first
200
- # consumer of a handoff; every later one, and any cache failure, is
201
- # treated as "no token" (fail closed).
283
+ # Uses the handoff up, durably first and then in the cache. Answers
284
+ # :consumed only to the first consumer of a handoff; a later one gets
285
+ # :already_shown, and any failure :withheld (fail closed).
202
286
  def consume_handoff(decoded_payload)
287
+ stored_id = decoded_payload["api_key_id"]
288
+ return refuse_inside_transaction(stored_id) if claim_could_roll_back?(stored_id)
289
+
290
+ durable = claim_durably(decoded_payload)
291
+ return durable unless durable.nil? || durable == :consumed
292
+
293
+ consume_cache_marker(decoded_payload)
294
+ rescue StandardError => error
295
+ log_warn "[ApiKeys Security] Token handoff could not be checked against the database (#{error.class}); withholding the token."
296
+ :withheld
297
+ end
298
+
299
+ # Whether this handoff has a durable claim that an open transaction on
300
+ # the api_keys connection could roll back after the token was shown.
301
+ # Raises on database errors (the caller withholds the token).
302
+ def claim_could_roll_back?(stored_id)
303
+ return false if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
304
+ return false unless ::ApiKeys::ApiKey.column_names.include?(::ApiKeys::ApiKey::TOKEN_SHOWN_AT_COLUMN)
305
+
306
+ ::ApiKeys::ApiKey.connection_pool.with_connection(&:transaction_open?)
307
+ end
308
+
309
+ # Inside a transaction the claim is not durable until the host commits,
310
+ # and the token would be returned before that. No cache can stand in
311
+ # for it: a process-local store protects one process, and after a
312
+ # rollback another process would release the token again. So the
313
+ # retrieval is refused and records nothing (no claim, no marker). Only
314
+ # a read tells an already-used handoff apart, which is not put back.
315
+ def refuse_inside_transaction(stored_id)
316
+ return :already_shown if ::ApiKeys::ApiKey.token_handoff_claimable?(stored_id) == false
317
+
318
+ log_warn "[ApiKeys Security] Token handoff retrieval ran inside an open database transaction, where its " \
319
+ "one-time claim could be rolled back; withholding the token and leaving the handoff in place. " \
320
+ "Retrieve it outside the transaction."
321
+ :inside_transaction
322
+ end
323
+
324
+ # The durable layer: the key's `token_shown_at`, claimed by one
325
+ # conditional UPDATE. nil when there is no durable layer for this
326
+ # handoff (the column does not exist yet, or the handoff names no key).
327
+ def claim_durably(decoded_payload)
328
+ stored_id = decoded_payload["api_key_id"]
329
+ return nil if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
330
+
331
+ case ::ApiKeys::ApiKey.claim_token_handoff!(stored_id)
332
+ when nil then nil
333
+ when true then :consumed
334
+ else :already_shown
335
+ end
336
+ rescue StandardError => error
337
+ log_warn "[ApiKeys Security] Token handoff could not be recorded in the database (#{error.class}); withholding the token."
338
+ :withheld
339
+ end
340
+
341
+ # The cache layer from 0.5.1: an atomic marker named by the handoff ID.
342
+ def consume_cache_marker(decoded_payload)
203
343
  cache = consumption_cache
204
- return true unless cache
344
+ return :consumed unless cache
205
345
 
206
346
  marker = consumption_marker_key(decoded_payload)
207
- return true unless marker
347
+ return :consumed unless marker
348
+ return :consumed if cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL)
208
349
 
209
- cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL) ? true : false
350
+ # Refused: normally the marker is already there (a replay), but some
351
+ # stores also answer false on error, so only a marker that is
352
+ # really present counts as "already shown".
353
+ cache.exist?(marker) ? :already_shown : :withheld
210
354
  rescue StandardError => error
211
355
  log_warn "[ApiKeys Security] Token handoff consumption could not be recorded (#{error.class}); withholding the token."
212
- false
356
+ :withheld
213
357
  end
214
358
 
359
+ # Read-only: whether either layer says the handoff was used up. A
360
+ # failed check reads as consumed (fail closed).
215
361
  def handoff_consumed?(decoded_payload)
362
+ durably_consumed?(decoded_payload) || cache_marker_present?(decoded_payload)
363
+ end
364
+
365
+ def durably_consumed?(decoded_payload)
366
+ stored_id = decoded_payload["api_key_id"]
367
+ return false if stored_id.nil? || !defined?(::ApiKeys::ApiKey)
368
+
369
+ ::ApiKeys::ApiKey.token_handoff_claimable?(stored_id) == false
370
+ rescue StandardError => error
371
+ log_warn "[ApiKeys Security] Token handoff consumption could not be checked in the database (#{error.class})."
372
+ true
373
+ end
374
+
375
+ def cache_marker_present?(decoded_payload)
216
376
  cache = consumption_cache
217
377
  return false unless cache
218
378
 
@@ -19,6 +19,39 @@ module ApiKeys
19
19
  # end
20
20
  #
21
21
  module ViewHelpers
22
+ # Plain-language reasons for every code ApiKeys::ApiKey::REFUSAL_CODES can
23
+ # stamp on a key. Written to finish the sentence "...refused, because".
24
+ REFUSAL_REASONS = {
25
+ "revoked_key" => "the key has been revoked",
26
+ "expired_key" => "the key has expired",
27
+ "unknown_key_type" => "the key's type is no longer configured",
28
+ "unknown_environment" => "the key's environment is no longer configured",
29
+ "environment_misconfigured" => "the key's environment could not be verified",
30
+ "environment_mismatch" => "the key was used in the wrong environment",
31
+ "origin_not_allowed" => "the request came from a web origin the key does not allow",
32
+ "ip_not_allowed" => "the request came from an IP address the key does not allow",
33
+ "restriction_misconfigured" => "the key's stored request restrictions could not be verified",
34
+ "missing_scope" => "the key lacks a permission the endpoint requires"
35
+ }.freeze
36
+ UNKNOWN_REFUSAL_REASON = "the request was refused for an unrecognized reason"
37
+
38
+ # Explains the most recently recorded refusal of a key: a request that
39
+ # presented the key's real token and was refused by a policy. Never echoes
40
+ # the stored code itself, so a tampered value cannot reach the page.
41
+ #
42
+ # @param api_key [ApiKeys::ApiKey] The API key
43
+ # @return [String, nil] A reason, or nil when no refusal has been recorded
44
+ #
45
+ # @example
46
+ # "Last refused because #{api_key_refusal_reason(@key)}"
47
+ # # => "Last refused because the request came from a web origin the key does not allow"
48
+ #
49
+ def api_key_refusal_reason(api_key)
50
+ return nil if api_key.last_refused_at.nil?
51
+
52
+ REFUSAL_REASONS.fetch(api_key.last_refusal_code.to_s, UNKNOWN_REFUSAL_REASON)
53
+ end
54
+
22
55
  # Returns the status of an API key as a symbol.
23
56
  #
24
57
  # @param api_key [ApiKeys::ApiKey] The API key
@@ -9,6 +9,11 @@ module ApiKeys
9
9
  module Jobs
10
10
  # Background job to update API key usage statistics (last_used_at, requests_count).
11
11
  # Enqueued by the Authentication concern after a successful request.
12
+ #
13
+ # With `refusal_code:` it records refused-but-identified traffic instead
14
+ # (last_refused_at, last_refusal_code, refusals_count). The two halves never
15
+ # share a write: a refusal is not a use, so it can never move last_used_at
16
+ # or requests_count, whatever the code says.
12
17
  class UpdateStatsJob < ActiveJob::Base
13
18
  include ApiKeys::Logging # Include logging helpers
14
19
 
@@ -21,7 +26,9 @@ module ApiKeys
21
26
  #
22
27
  # @param api_key_id [Integer, String] The ID of the ApiKey to update.
23
28
  # @param timestamp [Time] The timestamp of the request (when it was authenticated).
24
- def perform(api_key_id, timestamp)
29
+ # @param refusal_code [String, nil] Present when the request was refused
30
+ # after the key was identified; one of ApiKeys::ApiKey::REFUSAL_CODES.
31
+ def perform(api_key_id, timestamp, refusal_code: nil)
25
32
  api_key = ApiKey.find_by(id: api_key_id)
26
33
 
27
34
  unless api_key
@@ -40,6 +47,13 @@ module ApiKeys
40
47
  return
41
48
  end
42
49
 
50
+ # Any refusal marker at all, even a blank or unrecognized one, takes the
51
+ # refusal branch: the usage branch runs only for a genuine success.
52
+ unless refusal_code.nil?
53
+ record_refusal(api_key, timestamp, refusal_code)
54
+ return
55
+ end
56
+
43
57
  log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id}"
44
58
 
45
59
  # Jobs may execute out of order. Update only when this event is newer so
@@ -68,6 +82,36 @@ module ApiKeys
68
82
  log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id} (#{error.class})."
69
83
  # Consider re-raising or using a dead-letter queue strategy depending on job system
70
84
  end
85
+
86
+ private
87
+
88
+ # Stamps a refusal. An installation that has not run
89
+ # `rails generate api_keys:add_key_activity` yet skips it quietly, so a
90
+ # deploy that ships code before the migration never fails these jobs.
91
+ def record_refusal(api_key, timestamp, refusal_code)
92
+ unless ApiKey.refusal_stats_columns?
93
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Refusal stats columns are missing; skipping. " \
94
+ "Run: rails generate api_keys:add_key_activity && rails db:migrate"
95
+ return
96
+ end
97
+
98
+ code = refusal_code.to_s
99
+ unless ApiKey::REFUSAL_CODES.include?(code)
100
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] Ignored an unrecognized refusal code for ApiKey ID: #{api_key.id}."
101
+ return
102
+ end
103
+
104
+ # Same ordering guard as last_used_at: an older refusal processed late
105
+ # cannot overwrite a newer one, and the code always matches its timestamp.
106
+ ApiKey
107
+ .where(id: api_key.id)
108
+ .where("last_refused_at IS NULL OR last_refused_at < ?", timestamp)
109
+ .update_all(last_refused_at: timestamp, last_refusal_code: code)
110
+
111
+ # Counted under the same switch as requests_count, which is also the only
112
+ # mode in which no refusal is debounced away, so the count is exact.
113
+ ApiKey.increment_counter(:refusals_count, api_key.id) if ApiKeys.configuration.track_requests_count
114
+ end
71
115
  end
72
116
  end
73
117
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end