api_keys 0.3.0 → 0.4.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 +51 -0
- data/README.md +101 -39
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +374 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
- data/lib/generators/api_keys/templates/initializer.rb +36 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- data/gemfiles/rails_8.1.gemfile +0 -21
|
@@ -110,7 +110,8 @@ module ApiKeys
|
|
|
110
110
|
# - :masked [String] The masked token (e.g., "sk_live_••••abc")
|
|
111
111
|
# - :full [String, nil] The full token (only for viewable public keys)
|
|
112
112
|
# - :viewable [Boolean] Whether the full token can be displayed
|
|
113
|
-
# - :type [Symbol]
|
|
113
|
+
# - :type [Symbol, String, nil] A known built-in type is a Symbol; custom
|
|
114
|
+
# untrusted values remain Strings so they are never interned as Symbols.
|
|
114
115
|
# - :environment [String, nil] The environment (e.g., "live", "test")
|
|
115
116
|
#
|
|
116
117
|
# @example
|
|
@@ -127,13 +128,22 @@ module ApiKeys
|
|
|
127
128
|
masked: object.masked_token,
|
|
128
129
|
full: object.viewable_token,
|
|
129
130
|
viewable: object.respond_to?(:public_key_type?) && object.public_key_type?,
|
|
130
|
-
type: object.key_type
|
|
131
|
+
type: safe_key_type(object.key_type),
|
|
131
132
|
environment: object.environment
|
|
132
133
|
}
|
|
133
134
|
end
|
|
134
135
|
|
|
135
136
|
private
|
|
136
137
|
|
|
138
|
+
def safe_key_type(value)
|
|
139
|
+
case value.to_s
|
|
140
|
+
when "publishable" then :publishable
|
|
141
|
+
when "secret" then :secret
|
|
142
|
+
when "" then nil
|
|
143
|
+
else value.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
137
147
|
def resolve_checked_scopes(scopes, checked)
|
|
138
148
|
case checked
|
|
139
149
|
when :all
|
|
@@ -24,7 +24,7 @@ module ApiKeys
|
|
|
24
24
|
{ label: "60 days", value: "60_days", days: 60 },
|
|
25
25
|
{ label: "90 days", value: "90_days", days: 90 },
|
|
26
26
|
{ label: "1 year", value: "365_days", days: 365 }
|
|
27
|
-
].freeze
|
|
27
|
+
].map(&:freeze).freeze
|
|
28
28
|
|
|
29
29
|
class << self
|
|
30
30
|
# Returns options suitable for a Rails select helper.
|
|
@@ -65,9 +65,13 @@ module ApiKeys
|
|
|
65
65
|
# ExpirationOptions.parse("30_days") # => 30.days.from_now
|
|
66
66
|
# ExpirationOptions.parse("no_expiration") # => nil
|
|
67
67
|
# ExpirationOptions.parse(nil) # => nil
|
|
68
|
-
# ExpirationOptions.parse("invalid") # =>
|
|
68
|
+
# ExpirationOptions.parse("invalid") # => raises ArgumentError
|
|
69
69
|
#
|
|
70
70
|
def parse(preset)
|
|
71
|
+
return nil if preset.nil? || preset == ""
|
|
72
|
+
unless preset.is_a?(String) && preset.valid_encoding? && preset.bytesize <= 32
|
|
73
|
+
raise ArgumentError, "Invalid API key expiration preset"
|
|
74
|
+
end
|
|
71
75
|
return nil if preset.blank? || preset == "no_expiration"
|
|
72
76
|
|
|
73
77
|
# Try to extract days from the preset string (e.g., "30_days" => 30)
|
|
@@ -81,7 +85,7 @@ module ApiKeys
|
|
|
81
85
|
known = DEFAULT_PRESETS.find { |p| p[:value] == preset }
|
|
82
86
|
return known[:days].days.from_now if known && known[:days]
|
|
83
87
|
|
|
84
|
-
|
|
88
|
+
raise ArgumentError, "Invalid API key expiration preset"
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
# Returns the default preset value (useful for form defaults)
|
|
@@ -102,6 +106,10 @@ module ApiKeys
|
|
|
102
106
|
end
|
|
103
107
|
|
|
104
108
|
def build_custom_presets(days_list, include_no_expiration)
|
|
109
|
+
unless days_list.is_a?(Array) && days_list.all? { |days| days.is_a?(Integer) && days.between?(1, 3650) }
|
|
110
|
+
raise ArgumentError, "Expiration presets must be an array of integers between 1 and 3650"
|
|
111
|
+
end
|
|
112
|
+
|
|
105
113
|
options = []
|
|
106
114
|
|
|
107
115
|
if include_no_expiration
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/numeric/time"
|
|
4
|
+
require "active_support/message_encryptor"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
3
7
|
module ApiKeys
|
|
4
8
|
module Helpers
|
|
5
9
|
# Helper for managing API key tokens in the session.
|
|
@@ -8,8 +12,8 @@ module ApiKeys
|
|
|
8
12
|
# the plaintext token is not stored in the database. This helper provides
|
|
9
13
|
# a clean interface for the "show token once" pattern:
|
|
10
14
|
#
|
|
11
|
-
# 1. After creating a key, store
|
|
12
|
-
# 2. On the success page, retrieve
|
|
15
|
+
# 1. After creating a key, store an encrypted, short-lived handoff in the session
|
|
16
|
+
# 2. On the success page, decrypt, retrieve, and clear the token
|
|
13
17
|
# 3. If the user refreshes, the token is gone
|
|
14
18
|
#
|
|
15
19
|
# @example In your controller
|
|
@@ -29,9 +33,28 @@ module ApiKeys
|
|
|
29
33
|
class TokenSession
|
|
30
34
|
# Default session key for storing the token
|
|
31
35
|
DEFAULT_SESSION_KEY = :api_keys_new_token
|
|
36
|
+
MAX_TOKEN_BYTESIZE = 512
|
|
37
|
+
MAX_CIPHERTEXT_BYTESIZE = 4096
|
|
38
|
+
MAX_KEY_ID_BYTESIZE = 128
|
|
39
|
+
HANDOFF_VERSION = 2
|
|
40
|
+
HANDOFF_TTL = 10.minutes
|
|
41
|
+
ENCRYPTION_CIPHER = "aes-256-gcm"
|
|
42
|
+
ENCRYPTION_SALT = "api_keys/token_session/v2"
|
|
43
|
+
ENCRYPTION_PURPOSE = "api_keys.token_session"
|
|
44
|
+
JSON_SERIALIZER = Module.new do
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def dump(value)
|
|
48
|
+
JSON.generate(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load(value)
|
|
52
|
+
JSON.parse(value)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
32
55
|
|
|
33
56
|
class << self
|
|
34
|
-
# Store an API key
|
|
57
|
+
# Store an encrypted API key-token handoff in the session for later retrieval.
|
|
35
58
|
#
|
|
36
59
|
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
37
60
|
# @param api_key [ApiKeys::ApiKey] The newly created API key
|
|
@@ -39,7 +62,21 @@ module ApiKeys
|
|
|
39
62
|
# @return [String] The token that was stored
|
|
40
63
|
def store(session, api_key, key: DEFAULT_SESSION_KEY)
|
|
41
64
|
token = api_key.respond_to?(:token) ? api_key.token : api_key.to_s
|
|
42
|
-
|
|
65
|
+
unless valid_token_payload?(token)
|
|
66
|
+
raise ArgumentError, "Cannot store an invalid API key token in the session"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
|
|
70
|
+
encrypted_payload = token_encryptor.encrypt_and_sign(
|
|
71
|
+
{ "token" => token, "api_key_id" => api_key_id },
|
|
72
|
+
expires_in: HANDOFF_TTL,
|
|
73
|
+
purpose: ENCRYPTION_PURPOSE
|
|
74
|
+
)
|
|
75
|
+
session[key] = {
|
|
76
|
+
"version" => HANDOFF_VERSION,
|
|
77
|
+
"ciphertext" => encrypted_payload,
|
|
78
|
+
"api_key_id" => api_key_id
|
|
79
|
+
}
|
|
43
80
|
token
|
|
44
81
|
end
|
|
45
82
|
|
|
@@ -49,8 +86,22 @@ module ApiKeys
|
|
|
49
86
|
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
50
87
|
# @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
|
|
51
88
|
# @return [String, nil] The token, or nil if not present
|
|
52
|
-
def retrieve_once(session, key: DEFAULT_SESSION_KEY)
|
|
53
|
-
session.delete(key)
|
|
89
|
+
def retrieve_once(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
|
|
90
|
+
payload = session.delete(key)
|
|
91
|
+
expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
|
|
92
|
+
|
|
93
|
+
# Plain string payloads from older versions remain readable only when
|
|
94
|
+
# the caller does not request ID binding.
|
|
95
|
+
return payload if expected_id.nil? && valid_token_payload?(payload)
|
|
96
|
+
decoded_payload = decode_payload(payload)
|
|
97
|
+
return nil unless decoded_payload
|
|
98
|
+
|
|
99
|
+
token = decoded_payload["token"] || decoded_payload[:token]
|
|
100
|
+
stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
|
|
101
|
+
return nil if expected_id && stored_id.to_s != expected_id.to_s
|
|
102
|
+
return nil unless valid_token_payload?(token)
|
|
103
|
+
|
|
104
|
+
token
|
|
54
105
|
end
|
|
55
106
|
|
|
56
107
|
# Check if a token is available in the session without removing it.
|
|
@@ -59,8 +110,92 @@ module ApiKeys
|
|
|
59
110
|
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
60
111
|
# @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
|
|
61
112
|
# @return [Boolean] true if a token is stored
|
|
62
|
-
def available?(session, key: DEFAULT_SESSION_KEY)
|
|
63
|
-
session[key]
|
|
113
|
+
def available?(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
|
|
114
|
+
payload = session[key]
|
|
115
|
+
expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
|
|
116
|
+
|
|
117
|
+
return valid_token_payload?(payload) if payload.is_a?(String) && expected_id.nil?
|
|
118
|
+
decoded_payload = decode_payload(payload)
|
|
119
|
+
return false unless decoded_payload
|
|
120
|
+
|
|
121
|
+
token = decoded_payload["token"] || decoded_payload[:token]
|
|
122
|
+
stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
|
|
123
|
+
return false if expected_id && stored_id.to_s != expected_id.to_s
|
|
124
|
+
|
|
125
|
+
valid_token_payload?(token)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def decode_payload(payload)
|
|
131
|
+
return nil unless payload.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
version_present = payload.key?("version") || payload.key?(:version)
|
|
134
|
+
return legacy_payload(payload) unless version_present
|
|
135
|
+
|
|
136
|
+
version = payload["version"] || payload[:version]
|
|
137
|
+
return nil unless version == HANDOFF_VERSION
|
|
138
|
+
|
|
139
|
+
ciphertext = payload["ciphertext"] || payload[:ciphertext]
|
|
140
|
+
outer_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
|
|
141
|
+
return nil unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_CIPHERTEXT_BYTESIZE
|
|
142
|
+
|
|
143
|
+
decoded = token_encryptor.decrypt_and_verify(ciphertext, purpose: ENCRYPTION_PURPOSE)
|
|
144
|
+
return nil unless decoded.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
|
|
147
|
+
return nil unless outer_id == inner_id
|
|
148
|
+
|
|
149
|
+
decoded
|
|
150
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def legacy_payload(payload)
|
|
155
|
+
token = payload["token"] || payload[:token]
|
|
156
|
+
stored_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
|
|
157
|
+
return nil unless valid_token_payload?(token)
|
|
158
|
+
|
|
159
|
+
{ "token" => token, "api_key_id" => stored_id }
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def normalize_api_key_id(value)
|
|
165
|
+
return nil if value.nil?
|
|
166
|
+
|
|
167
|
+
normalized = value.to_s
|
|
168
|
+
valid = normalized.present? && normalized.valid_encoding? &&
|
|
169
|
+
normalized.bytesize <= MAX_KEY_ID_BYTESIZE &&
|
|
170
|
+
normalized.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
171
|
+
raise ArgumentError, "API key ID is invalid" unless valid
|
|
172
|
+
|
|
173
|
+
normalized
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
raise ArgumentError, "API key ID is invalid"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def token_encryptor
|
|
179
|
+
application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
180
|
+
unless application&.respond_to?(:key_generator)
|
|
181
|
+
raise ArgumentError, "Rails.application.key_generator is required for secure token handoff"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
key_length = ActiveSupport::MessageEncryptor.key_len(ENCRYPTION_CIPHER)
|
|
185
|
+
encryption_key = application.key_generator.generate_key(ENCRYPTION_SALT, key_length)
|
|
186
|
+
ActiveSupport::MessageEncryptor.new(
|
|
187
|
+
encryption_key,
|
|
188
|
+
cipher: ENCRYPTION_CIPHER,
|
|
189
|
+
serializer: JSON_SERIALIZER
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def valid_token_payload?(token)
|
|
194
|
+
token.is_a?(String) && token.present? && token.valid_encoding? &&
|
|
195
|
+
token.bytesize <= MAX_TOKEN_BYTESIZE &&
|
|
196
|
+
token.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
197
|
+
rescue ArgumentError
|
|
198
|
+
false
|
|
64
199
|
end
|
|
65
200
|
end
|
|
66
201
|
end
|
|
@@ -185,7 +185,11 @@ module ApiKeys
|
|
|
185
185
|
# # => { type: :publishable, label: "Publishable", color: :green }
|
|
186
186
|
#
|
|
187
187
|
def api_key_type_info(api_key)
|
|
188
|
-
type = api_key.key_type.
|
|
188
|
+
type = case api_key.key_type.to_s
|
|
189
|
+
when "", "secret" then :secret
|
|
190
|
+
when "publishable" then :publishable
|
|
191
|
+
else api_key.key_type.to_s
|
|
192
|
+
end
|
|
189
193
|
{
|
|
190
194
|
type: type,
|
|
191
195
|
label: api_key_type_label(api_key),
|
|
@@ -10,8 +10,8 @@ module ApiKeys
|
|
|
10
10
|
class CallbacksJob < ActiveJob::Base
|
|
11
11
|
include ApiKeys::Logging
|
|
12
12
|
|
|
13
|
-
#
|
|
14
|
-
queue_as ApiKeys.configuration.callbacks_job_queue
|
|
13
|
+
# Resolve the configured queue for each job so initializer changes apply.
|
|
14
|
+
queue_as { ApiKeys.configuration.callbacks_job_queue }
|
|
15
15
|
|
|
16
16
|
# Executes the appropriate callback based on the type.
|
|
17
17
|
#
|
|
@@ -28,10 +28,8 @@ module ApiKeys
|
|
|
28
28
|
else
|
|
29
29
|
log_warn "[ApiKeys::Jobs::CallbacksJob] Unknown callback type: #{callback_type}"
|
|
30
30
|
end
|
|
31
|
-
rescue StandardError =>
|
|
32
|
-
log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type}
|
|
33
|
-
#{e.backtrace.join("
|
|
34
|
-
")}"
|
|
31
|
+
rescue StandardError => error
|
|
32
|
+
log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} (#{error.class})."
|
|
35
33
|
# Avoid retrying callback errors by default, as the original request succeeded.
|
|
36
34
|
# Depending on callback importance, users might configure retries separately.
|
|
37
35
|
end
|
|
@@ -51,17 +49,12 @@ module ApiKeys
|
|
|
51
49
|
arity = callback_proc.arity
|
|
52
50
|
log_debug "[ApiKeys::Jobs::CallbacksJob] Executing callback with arity #{arity}"
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
|
|
61
|
-
end
|
|
62
|
-
rescue StandardError => e
|
|
63
|
-
# Log the specific error from the user's callback code
|
|
64
|
-
raise # Re-raise to be caught by the main perform rescue block for logging
|
|
52
|
+
if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
|
|
53
|
+
callback_proc.call(context)
|
|
54
|
+
elsif arity == 0 # Handle procs accepting no args
|
|
55
|
+
callback_proc.call
|
|
56
|
+
else
|
|
57
|
+
log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
|
|
65
58
|
end
|
|
66
59
|
end
|
|
67
60
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_job"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
4
5
|
require_relative "../models/api_key"
|
|
5
6
|
require_relative "../logging"
|
|
6
7
|
|
|
@@ -11,8 +12,10 @@ module ApiKeys
|
|
|
11
12
|
class UpdateStatsJob < ActiveJob::Base
|
|
12
13
|
include ApiKeys::Logging # Include logging helpers
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
MAX_FUTURE_CLOCK_SKEW = 5.minutes
|
|
16
|
+
|
|
17
|
+
# Resolve the configured queue for each job so initializer changes apply.
|
|
18
|
+
queue_as { ApiKeys.configuration.stats_job_queue }
|
|
16
19
|
|
|
17
20
|
# Perform the database updates for the given ApiKey.
|
|
18
21
|
#
|
|
@@ -26,11 +29,25 @@ module ApiKeys
|
|
|
26
29
|
return
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
unless timestamp.respond_to?(:to_time)
|
|
33
|
+
log_warn "[ApiKeys::Jobs::UpdateStatsJob] Invalid timestamp for ApiKey ID: #{api_key_id}. Skipping stats update."
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
timestamp = timestamp.to_time
|
|
37
|
+
|
|
38
|
+
if timestamp > Time.current + MAX_FUTURE_CLOCK_SKEW
|
|
39
|
+
log_warn "[ApiKeys::Jobs::UpdateStatsJob] Rejected a timestamp too far in the future."
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id}"
|
|
30
44
|
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
45
|
+
# Jobs may execute out of order. Update only when this event is newer so
|
|
46
|
+
# last_used_at can never regress to an earlier request timestamp.
|
|
47
|
+
ApiKey
|
|
48
|
+
.where(id: api_key.id)
|
|
49
|
+
.where("last_used_at IS NULL OR last_used_at < ?", timestamp)
|
|
50
|
+
.update_all(last_used_at: timestamp)
|
|
34
51
|
|
|
35
52
|
# Conditionally increment requests_count if configured
|
|
36
53
|
if ApiKeys.configuration.track_requests_count
|
|
@@ -41,16 +58,14 @@ module ApiKeys
|
|
|
41
58
|
|
|
42
59
|
log_debug "[ApiKeys::Jobs::UpdateStatsJob] Finished updating stats for ApiKey ID: #{api_key_id}"
|
|
43
60
|
|
|
44
|
-
rescue ActiveRecord::ActiveRecordError =>
|
|
61
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
45
62
|
# Log error but don't automatically retry unless configured to do so.
|
|
46
63
|
# Frequent stats updates might tolerate occasional failures better than endless retries.
|
|
47
|
-
log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id}
|
|
64
|
+
log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id} (#{error.class})."
|
|
48
65
|
# Depending on ActiveJob adapter, specific retry logic might be needed here
|
|
49
66
|
# or configured globally. For now, just log.
|
|
50
|
-
rescue StandardError =>
|
|
51
|
-
log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id}
|
|
52
|
-
#{e.backtrace.join("
|
|
53
|
-
")}"
|
|
67
|
+
rescue StandardError => error
|
|
68
|
+
log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id} (#{error.class})."
|
|
54
69
|
# Consider re-raising or using a dead-letter queue strategy depending on job system
|
|
55
70
|
end
|
|
56
71
|
end
|