api_keys 0.2.1 → 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 +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- 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 +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- data/Rakefile +0 -32
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
|
+
require "json"
|
|
4
5
|
require_relative "../services/token_generator"
|
|
5
6
|
require_relative "../services/digestor"
|
|
6
7
|
|
|
7
8
|
module ApiKeys
|
|
8
9
|
# The core ActiveRecord model representing an API key.
|
|
9
10
|
class ApiKey < ActiveRecord::Base
|
|
11
|
+
MAX_SCOPES = 100
|
|
12
|
+
MAX_SCOPE_BYTESIZE = 128
|
|
13
|
+
MAX_METADATA_BYTESIZE = 16_384
|
|
14
|
+
IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
|
|
15
|
+
token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
10
18
|
self.table_name = "api_keys"
|
|
11
19
|
|
|
12
20
|
# == Concerns ==
|
|
@@ -22,10 +30,25 @@ module ApiKeys
|
|
|
22
30
|
# JSON attributes (:scopes, :metadata) are defined in the engine initializer
|
|
23
31
|
# using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
|
|
24
32
|
|
|
33
|
+
# Override scopes setter to auto-clean blank values.
|
|
34
|
+
# This handles the common case where form checkboxes submit empty strings.
|
|
35
|
+
# Works for both create and update operations.
|
|
36
|
+
def scopes=(value)
|
|
37
|
+
cleaned = if value.is_a?(Array)
|
|
38
|
+
value
|
|
39
|
+
.map { |scope| scope.is_a?(Symbol) ? scope.to_s : scope }
|
|
40
|
+
.reject { |scope| scope.respond_to?(:blank?) && scope.blank? }
|
|
41
|
+
.uniq
|
|
42
|
+
else
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
super(cleaned)
|
|
46
|
+
end
|
|
47
|
+
|
|
25
48
|
# == Validations ==
|
|
26
49
|
validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
|
|
27
|
-
validates :prefix, presence: true
|
|
28
|
-
validates :digest_algorithm, presence: true
|
|
50
|
+
validates :prefix, presence: true, length: { maximum: 64 }
|
|
51
|
+
validates :digest_algorithm, presence: true, inclusion: { in: %w[sha256 bcrypt] }
|
|
29
52
|
validates :last4, presence: true, length: { is: 4 }
|
|
30
53
|
# validates :scopes, presence: true # Default handled by attribute def
|
|
31
54
|
# validates :metadata, presence: true # Default handled by attribute def
|
|
@@ -40,6 +63,17 @@ module ApiKeys
|
|
|
40
63
|
|
|
41
64
|
# TODO: Add validation for expires_at > Time.current if present
|
|
42
65
|
validate :expiration_date_cannot_be_in_the_past, if: :expires_at?
|
|
66
|
+
validate :within_key_type_limit, on: :create, if: -> { key_type.present? && owner.present? }
|
|
67
|
+
validate :non_revocable_keys_cannot_expire, if: -> { key_type.present? && expires_at.present? }
|
|
68
|
+
validate :scopes_are_well_formed
|
|
69
|
+
validate :scopes_respect_permission_ceiling
|
|
70
|
+
validate :key_type_present_when_feature_enabled, on: :create
|
|
71
|
+
validate :key_type_is_configured, if: -> { key_type.present? }
|
|
72
|
+
validate :environment_is_configured, if: -> { key_type.present? }
|
|
73
|
+
validate :token_digest_matches_algorithm
|
|
74
|
+
validate :token_identifiers_are_well_formed
|
|
75
|
+
validate :metadata_is_well_formed
|
|
76
|
+
validate :authentication_identity_is_immutable, on: :update
|
|
43
77
|
|
|
44
78
|
# TODO: Add validation for scope string format
|
|
45
79
|
# TODO: Add validation for prefix format (e.g., must end with _)
|
|
@@ -48,6 +82,10 @@ module ApiKeys
|
|
|
48
82
|
before_validation :set_defaults, on: :create
|
|
49
83
|
# Generate digest BEFORE validation runs
|
|
50
84
|
before_validation :generate_token_and_digest, on: :create
|
|
85
|
+
# Serialize quota validation and insertion for every creation path, including
|
|
86
|
+
# direct ApiKey.create! calls that do not use HasApiKeys#create_api_key!.
|
|
87
|
+
before_validation :lock_owner_for_creation, on: :create
|
|
88
|
+
after_commit :clear_known_prefixes_cache, on: :create
|
|
51
89
|
|
|
52
90
|
# == Scopes ==
|
|
53
91
|
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
@@ -56,11 +94,53 @@ module ApiKeys
|
|
|
56
94
|
scope :inactive, -> { revoked.or(expired) }
|
|
57
95
|
scope :for_prefix, ->(prefix) { where(prefix: prefix) }
|
|
58
96
|
scope :for_owner, ->(owner) { where(owner: owner) }
|
|
59
|
-
|
|
97
|
+
scope :for_key_type, ->(key_type) { where(key_type: key_type.to_s) }
|
|
98
|
+
scope :for_environment, ->(environment) { where(environment: environment.to_s) }
|
|
99
|
+
|
|
100
|
+
# Convenience scopes for key types
|
|
101
|
+
# .publishable returns only keys with key_type: "publishable"
|
|
102
|
+
# .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type)
|
|
103
|
+
scope :publishable, -> { where(key_type: "publishable") }
|
|
104
|
+
scope :secret, -> { where.not(key_type: "publishable") }
|
|
105
|
+
|
|
106
|
+
# === Usage Analytics Scopes ===
|
|
107
|
+
# These scopes help admin dashboards analyze API key usage patterns.
|
|
108
|
+
# Useful for identifying unused keys, high-traffic keys, and stale keys that may need cleanup.
|
|
109
|
+
|
|
110
|
+
# Keys that have never been used (last_used_at is nil)
|
|
111
|
+
scope :never_used, -> { where(last_used_at: nil) }
|
|
112
|
+
|
|
113
|
+
# Keys that have been used at least once
|
|
114
|
+
scope :used, -> { where.not(last_used_at: nil) }
|
|
115
|
+
|
|
116
|
+
# Order by usage count (highest first) - useful for finding most active keys
|
|
117
|
+
scope :by_requests, -> { order(requests_count: :desc) }
|
|
118
|
+
|
|
119
|
+
# Order by last used time (most recent first, nulls last)
|
|
120
|
+
# Uses NULLS LAST for PostgreSQL compatibility; SQLite sorts nulls last by default with DESC
|
|
121
|
+
scope :by_last_used, -> { order(Arel.sql("CASE WHEN last_used_at IS NULL THEN 1 ELSE 0 END, last_used_at DESC")) }
|
|
122
|
+
|
|
123
|
+
# Active keys that haven't been used within the specified period.
|
|
124
|
+
# Useful for identifying keys that may have been abandoned or forgotten.
|
|
125
|
+
# Excludes revoked/expired keys since those are already inactive.
|
|
126
|
+
# @param period [ActiveSupport::Duration] The inactivity threshold (default: 30 days)
|
|
127
|
+
scope :stale, ->(period = 30.days) {
|
|
128
|
+
active.where("last_used_at < :threshold OR last_used_at IS NULL", threshold: period.ago)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Aliases for common admin dashboard naming conventions
|
|
132
|
+
class << self
|
|
133
|
+
alias_method :most_used, :by_requests
|
|
134
|
+
alias_method :recently_used, :by_last_used
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convenience scope for 30-day stale keys (common admin filter)
|
|
138
|
+
scope :inactive_for_30_days, -> { stale(30.days) }
|
|
60
139
|
|
|
61
140
|
# == Instance Methods ==
|
|
62
141
|
|
|
63
142
|
def revoke!
|
|
143
|
+
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
64
144
|
update!(revoked_at: Time.current)
|
|
65
145
|
end
|
|
66
146
|
|
|
@@ -76,14 +156,138 @@ module ApiKeys
|
|
|
76
156
|
!revoked? && !expired?
|
|
77
157
|
end
|
|
78
158
|
|
|
159
|
+
# The plaintext token is an ephemeral creation-time value. Active Record's
|
|
160
|
+
# reload does not clear arbitrary instance variables, so clear it explicitly.
|
|
161
|
+
def reload(...)
|
|
162
|
+
@token = nil
|
|
163
|
+
super
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Keep credentials and credential-derived values out of logs and consoles.
|
|
167
|
+
def inspect
|
|
168
|
+
attributes = %w[id prefix last4 name owner_type owner_id key_type environment expires_at revoked_at]
|
|
169
|
+
.select { |attribute_name| has_attribute?(attribute_name) }
|
|
170
|
+
.map { |attribute_name| "#{attribute_name}: #{attribute_for_inspect(attribute_name)}" }
|
|
171
|
+
"#<#{self.class.name} #{attributes.join(', ')}>"
|
|
172
|
+
rescue StandardError
|
|
173
|
+
"#<#{self.class.name}>"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def pretty_print(printer)
|
|
177
|
+
printer.text(inspect)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Rendering a model as JSON must never expose the verification digest or a
|
|
181
|
+
# public token stored in the reserved metadata field. Call #viewable_token
|
|
182
|
+
# explicitly when an authenticated UI intentionally needs a public token.
|
|
183
|
+
def serializable_hash(options = nil)
|
|
184
|
+
serialized = super(options)
|
|
185
|
+
serialized.delete("token_digest")
|
|
186
|
+
serialized.delete(:token_digest)
|
|
187
|
+
|
|
188
|
+
metadata_value = serialized["metadata"] || serialized[:metadata]
|
|
189
|
+
if metadata_value.is_a?(Hash)
|
|
190
|
+
sanitized_metadata = metadata_value.dup
|
|
191
|
+
sanitized_metadata.delete("token")
|
|
192
|
+
sanitized_metadata.delete(:token)
|
|
193
|
+
serialized[serialized.key?("metadata") ? "metadata" : :metadata] = sanitized_metadata
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
serialized
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Returns true if this key can be revoked/destroyed
|
|
200
|
+
# Keys without a key_type (legacy) are always revocable
|
|
201
|
+
# Keys with a key_type check the configuration
|
|
202
|
+
def revocable?
|
|
203
|
+
return true if key_type.blank?
|
|
204
|
+
config = key_type_config
|
|
205
|
+
return false if config.nil?
|
|
206
|
+
config.fetch(:revocable, true)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Returns the configuration hash for this key's type
|
|
210
|
+
def key_type_config
|
|
211
|
+
return nil if key_type.blank?
|
|
212
|
+
configured_pair = ApiKeys.configuration.key_types&.find { |type, _settings| type.to_s == key_type.to_s }
|
|
213
|
+
configured_pair&.last
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Returns the configuration hash for this key's environment
|
|
217
|
+
def environment_config
|
|
218
|
+
return nil if environment.blank?
|
|
219
|
+
configured_pair = ApiKeys.configuration.environments&.find { |name, _settings| name.to_s == environment.to_s }
|
|
220
|
+
configured_pair&.last
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Returns true if this key type is configured as public AND non-revocable.
|
|
224
|
+
# Only these keys have their plaintext token stored in metadata for later viewing.
|
|
225
|
+
# This is used for publishable keys that are designed to be embedded in distributed apps.
|
|
226
|
+
def public_key_type?
|
|
227
|
+
return false if key_type.blank?
|
|
228
|
+
config = key_type_config
|
|
229
|
+
return false if config.nil?
|
|
230
|
+
config[:public] == true && config[:revocable] == false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns the stored plaintext token for public, non-revocable keys.
|
|
234
|
+
# Returns nil for all other key types (the token is only available at creation time).
|
|
235
|
+
# @return [String, nil] The full plaintext token, or nil if not stored
|
|
236
|
+
def viewable_token
|
|
237
|
+
return nil unless public_key_type?
|
|
238
|
+
metadata&.dig("token")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Override destroy to prevent destroying non-revocable keys
|
|
242
|
+
def destroy
|
|
243
|
+
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
244
|
+
super
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def destroy!
|
|
248
|
+
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
249
|
+
super
|
|
250
|
+
end
|
|
251
|
+
|
|
79
252
|
# Basic scope check. Assumes scopes are stored as an array of strings.
|
|
80
|
-
#
|
|
253
|
+
#
|
|
254
|
+
# Behavior depends on whether key_types mode is enabled:
|
|
255
|
+
# - Simple mode (no key_types): blank scopes means "unrestricted" (all scopes allowed).
|
|
256
|
+
# This preserves backwards compatibility for apps that don't use scopes at all.
|
|
257
|
+
# - Key types mode: blank scopes means "no permissions". When you've configured
|
|
258
|
+
# key types with permission ceilings, an empty scope list should deny access,
|
|
259
|
+
# not silently bypass the entire permission system.
|
|
81
260
|
def allows_scope?(required_scope)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
scopes.blank?
|
|
261
|
+
return false unless respond_to?(:scopes)
|
|
262
|
+
return false unless required_scope.present?
|
|
263
|
+
return false unless scopes.is_a?(Array)
|
|
264
|
+
|
|
265
|
+
if scopes.blank?
|
|
266
|
+
return !scope_policy_enabled?
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
required = required_scope.to_s
|
|
270
|
+
return false unless scopes.all? { |scope| valid_scope_value?(scope) }
|
|
271
|
+
return false unless scopes.include?(required)
|
|
272
|
+
|
|
273
|
+
ceiling = permission_ceiling
|
|
274
|
+
ceiling == :all || ceiling.include?(required)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Alias for scopes - provides a more user-friendly API that matches
|
|
278
|
+
# the configuration DSL where key types use `permissions` for scope ceiling.
|
|
279
|
+
# Note: We use a method instead of alias_method because `scopes` is defined
|
|
280
|
+
# dynamically via the `attribute` API in the engine initializer.
|
|
281
|
+
# @return [Array<String>] The permissions (scopes) assigned to this key
|
|
282
|
+
def permissions
|
|
283
|
+
scopes
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Check if this key has a specific permission (alias for allows_scope?)
|
|
287
|
+
# @param required_permission [String, Symbol] The permission to check
|
|
288
|
+
# @return [Boolean] true if the key has this permission or has no restrictions
|
|
289
|
+
def allows_permission?(required_permission)
|
|
290
|
+
allows_scope?(required_permission)
|
|
87
291
|
end
|
|
88
292
|
|
|
89
293
|
# Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
|
|
@@ -108,25 +312,49 @@ module ApiKeys
|
|
|
108
312
|
def set_defaults
|
|
109
313
|
# NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
|
|
110
314
|
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
315
|
+
# If key_types feature is enabled (non-empty key_types config), use type+env prefix
|
|
316
|
+
if key_types_feature_enabled? && key_type.present?
|
|
317
|
+
self.prefix ||= build_typed_prefix
|
|
318
|
+
else
|
|
319
|
+
# Legacy behavior: use owner-specific or global config prefix
|
|
320
|
+
owner_prefix_config = nil
|
|
321
|
+
if owner.present? && owner.class.respond_to?(:api_keys_settings)
|
|
322
|
+
owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
|
|
323
|
+
end
|
|
117
324
|
|
|
118
|
-
|
|
119
|
-
|
|
325
|
+
# Use owner setting if present, otherwise fall back to global config
|
|
326
|
+
prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
|
|
120
327
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
328
|
+
# Evaluate the prefix config (it might be a Proc)
|
|
329
|
+
self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
|
|
330
|
+
end
|
|
124
331
|
|
|
125
332
|
# Removed default scopes logic here. It's correctly handled in the
|
|
126
333
|
# HasApiKeys#create_api_key! helper method, which is the intended
|
|
127
334
|
# way to create keys with proper default scope application.
|
|
128
335
|
end
|
|
129
336
|
|
|
337
|
+
# Build prefix from key_type and environment configuration
|
|
338
|
+
# e.g., publishable + test → "pk_test_"
|
|
339
|
+
def build_typed_prefix
|
|
340
|
+
type_config = key_type_config
|
|
341
|
+
env_config = environment_config
|
|
342
|
+
|
|
343
|
+
type_prefix = type_config&.dig(:prefix) || key_type.to_s[0..1]
|
|
344
|
+
env_segment = env_config&.dig(:prefix_segment)
|
|
345
|
+
|
|
346
|
+
if env_segment.present?
|
|
347
|
+
"#{type_prefix}_#{env_segment}_"
|
|
348
|
+
else
|
|
349
|
+
"#{type_prefix}_"
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Check if key types feature is enabled
|
|
354
|
+
def key_types_feature_enabled?
|
|
355
|
+
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
|
|
356
|
+
end
|
|
357
|
+
|
|
130
358
|
# Generates the secure token, hashes it, and sets relevant attributes.
|
|
131
359
|
# Called before validation on create.
|
|
132
360
|
def generate_token_and_digest
|
|
@@ -142,7 +370,8 @@ module ApiKeys
|
|
|
142
370
|
|
|
143
371
|
# Safety check: Ensure generated token starts with the expected prefix
|
|
144
372
|
unless @token.start_with?(self.prefix)
|
|
145
|
-
|
|
373
|
+
@token = nil
|
|
374
|
+
raise ApiKeys::Error, "Generated token does not match the configured prefix. Check TokenGenerator configuration."
|
|
146
375
|
end
|
|
147
376
|
|
|
148
377
|
# Use the configured digestor
|
|
@@ -160,10 +389,175 @@ module ApiKeys
|
|
|
160
389
|
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
|
|
161
390
|
self.expires_at = ApiKeys.configuration.expire_after.from_now
|
|
162
391
|
end
|
|
392
|
+
|
|
393
|
+
# Store plaintext token in metadata for public, non-revocable keys.
|
|
394
|
+
# This allows users to view the token again in the dashboard.
|
|
395
|
+
# SECURITY: Only do this for keys explicitly configured as public: true
|
|
396
|
+
# AND revocable: false (e.g., publishable keys for distributed apps).
|
|
397
|
+
if public_key_type?
|
|
398
|
+
self.metadata = (self.metadata || {}).merge("token" => @token)
|
|
399
|
+
end
|
|
163
400
|
end
|
|
164
401
|
|
|
165
402
|
# == Validation Helpers ==
|
|
166
403
|
|
|
404
|
+
def lock_owner_for_creation
|
|
405
|
+
return unless owner&.persisted?
|
|
406
|
+
|
|
407
|
+
# Query a separate relation so locking does not reload or discard unsaved
|
|
408
|
+
# attributes on the caller's in-memory owner object. `unscoped` ensures a
|
|
409
|
+
# tenant/default scope cannot accidentally bypass quota serialization.
|
|
410
|
+
owner.class.unscoped.lock(true).find(owner.id)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def scopes_are_well_formed
|
|
414
|
+
unless scopes.is_a?(Array)
|
|
415
|
+
errors.add(:scopes, "must be an array")
|
|
416
|
+
return
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
if scopes.length > MAX_SCOPES
|
|
420
|
+
errors.add(:scopes, "cannot contain more than #{MAX_SCOPES} entries")
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
unless scopes.all? { |scope| valid_scope_value?(scope) }
|
|
424
|
+
errors.add(:scopes, "must contain only non-blank strings of at most #{MAX_SCOPE_BYTESIZE} bytes without whitespace or control characters")
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def token_digest_matches_algorithm
|
|
429
|
+
return if token_digest.blank? || digest_algorithm.blank?
|
|
430
|
+
|
|
431
|
+
valid = case digest_algorithm.to_s
|
|
432
|
+
when "sha256"
|
|
433
|
+
token_digest.is_a?(String) && token_digest.match?(/\A\h{64}\z/)
|
|
434
|
+
when "bcrypt"
|
|
435
|
+
ApiKeys::Services::Digestor.valid_bcrypt_digest?(token_digest)
|
|
436
|
+
else
|
|
437
|
+
true # The inclusion validation reports unsupported algorithms.
|
|
438
|
+
end
|
|
439
|
+
errors.add(:token_digest, "is not a valid #{digest_algorithm} digest") unless valid
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def token_identifiers_are_well_formed
|
|
443
|
+
unless safe_token_component?(prefix, maximum_bytes: 64)
|
|
444
|
+
errors.add(:prefix, "must not contain whitespace or control characters")
|
|
445
|
+
end
|
|
446
|
+
unless safe_token_component?(last4, maximum_bytes: 4)
|
|
447
|
+
errors.add(:last4, "must not contain whitespace or control characters")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
%i[key_type environment].each do |attribute_name|
|
|
451
|
+
value = public_send(attribute_name)
|
|
452
|
+
next if value.blank?
|
|
453
|
+
next if value.is_a?(String) && value.bytesize <= 64 && value.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
454
|
+
|
|
455
|
+
errors.add(attribute_name, "must contain only letters, numbers, underscores, or hyphens (maximum 64 bytes)")
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def metadata_is_well_formed
|
|
460
|
+
unless metadata.is_a?(Hash)
|
|
461
|
+
errors.add(:metadata, "must be an object")
|
|
462
|
+
return
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
errors.add(:metadata, "is too large") if JSON.generate(metadata).bytesize > MAX_METADATA_BYTESIZE
|
|
466
|
+
rescue JSON::GeneratorError, EncodingError
|
|
467
|
+
errors.add(:metadata, "must contain valid JSON data")
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def authentication_identity_is_immutable
|
|
471
|
+
IMMUTABLE_IDENTITY_ATTRIBUTES.each do |attribute_name|
|
|
472
|
+
next unless will_save_change_to_attribute?(attribute_name)
|
|
473
|
+
|
|
474
|
+
errors.add(attribute_name, "cannot be changed after creation")
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def scopes_respect_permission_ceiling
|
|
479
|
+
return unless scopes.is_a?(Array)
|
|
480
|
+
|
|
481
|
+
ceiling = permission_ceiling
|
|
482
|
+
return if ceiling == :all
|
|
483
|
+
return if scopes.all? { |scope| ceiling.include?(scope) }
|
|
484
|
+
|
|
485
|
+
errors.add(:scopes, "exceed the configured permission ceiling")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def key_type_is_configured
|
|
489
|
+
return if key_type_config
|
|
490
|
+
|
|
491
|
+
errors.add(:key_type, "is not configured")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def key_type_present_when_feature_enabled
|
|
495
|
+
return unless key_types_feature_enabled?
|
|
496
|
+
return if key_type.present?
|
|
497
|
+
|
|
498
|
+
errors.add(:key_type, "must be present when key types are configured")
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def environment_is_configured
|
|
502
|
+
if environment.blank?
|
|
503
|
+
errors.add(:environment, "must be present for typed API keys")
|
|
504
|
+
return
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
configured_environments = ApiKeys.configuration.environments
|
|
508
|
+
return if configured_environments.blank? || environment_config
|
|
509
|
+
|
|
510
|
+
errors.add(:environment, "is not configured")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def valid_scope_value?(scope)
|
|
514
|
+
scope.is_a?(String) && scope.present? && scope.valid_encoding? &&
|
|
515
|
+
scope.bytesize <= MAX_SCOPE_BYTESIZE &&
|
|
516
|
+
scope.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
517
|
+
rescue ArgumentError
|
|
518
|
+
false
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def safe_token_component?(value, maximum_bytes:)
|
|
522
|
+
value.is_a?(String) && value.present? && value.valid_encoding? && value.bytesize <= maximum_bytes &&
|
|
523
|
+
value.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
524
|
+
rescue ArgumentError
|
|
525
|
+
false
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def permission_ceiling
|
|
529
|
+
if key_type.present?
|
|
530
|
+
config = key_type_config
|
|
531
|
+
return [] unless config
|
|
532
|
+
|
|
533
|
+
permissions = config[:permissions]
|
|
534
|
+
return :all if permissions == :all
|
|
535
|
+
|
|
536
|
+
return Array(permissions).map(&:to_s)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
configured_simple_scopes = simple_scope_configuration
|
|
540
|
+
configured_simple_scopes.present? ? configured_simple_scopes : :all
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def scope_policy_enabled?
|
|
544
|
+
key_type.present? || ApiKeys.configuration.key_types.present? || simple_scope_configuration.present?
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def simple_scope_configuration
|
|
548
|
+
owner_scopes = if owner&.class.respond_to?(:api_keys_settings)
|
|
549
|
+
owner.class.api_keys_settings&.[](:default_scopes)
|
|
550
|
+
end
|
|
551
|
+
configured = owner_scopes.presence || ApiKeys.configuration.default_scopes
|
|
552
|
+
Array(configured).map(&:to_s)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def clear_known_prefixes_cache
|
|
556
|
+
return unless defined?(ApiKeys::Services::Authenticator)
|
|
557
|
+
|
|
558
|
+
ApiKeys::Services::Authenticator.clear_known_prefixes_cache
|
|
559
|
+
end
|
|
560
|
+
|
|
167
561
|
def owner_present_and_configured?
|
|
168
562
|
owner.present? && owner_configured?
|
|
169
563
|
end
|
|
@@ -205,5 +599,42 @@ module ApiKeys
|
|
|
205
599
|
errors.add(:expires_at, "can't be in the past") if expires_at.present? && expires_at < Time.current
|
|
206
600
|
end
|
|
207
601
|
|
|
602
|
+
# Non-revocable keys cannot have expiration dates.
|
|
603
|
+
# If a key expires but cannot be revoked/deleted, the user would be stuck
|
|
604
|
+
# with a useless expired key they can't remove.
|
|
605
|
+
def non_revocable_keys_cannot_expire
|
|
606
|
+
return unless key_type.present? && expires_at.present?
|
|
607
|
+
|
|
608
|
+
config = key_type_config
|
|
609
|
+
return unless config # No config = allow (legacy behavior)
|
|
610
|
+
|
|
611
|
+
# If this key type is non-revocable, prevent setting expiration
|
|
612
|
+
if config[:revocable] == false
|
|
613
|
+
errors.add(:expires_at, "cannot be set on non-revocable keys (#{key_type} keys cannot be revoked or deleted)")
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Check if creating this key would exceed the limit for this key type/environment.
|
|
618
|
+
# HasApiKeys#create_api_key! locks the owner row around this validation and insert.
|
|
619
|
+
def within_key_type_limit
|
|
620
|
+
return unless key_types_feature_enabled?
|
|
621
|
+
|
|
622
|
+
config = key_type_config
|
|
623
|
+
return unless config # No config = no limit
|
|
624
|
+
|
|
625
|
+
limit = config[:limit]
|
|
626
|
+
return unless limit # nil limit = unlimited
|
|
627
|
+
|
|
628
|
+
existing_count = owner.api_keys
|
|
629
|
+
.active
|
|
630
|
+
.where(key_type: key_type.to_s)
|
|
631
|
+
.where(environment: environment.to_s)
|
|
632
|
+
.count
|
|
633
|
+
|
|
634
|
+
if existing_count >= limit
|
|
635
|
+
errors.add(:base, "Maximum number of #{key_type} keys (#{limit}) reached for #{environment} environment")
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
208
639
|
end
|
|
209
640
|
end
|