api_keys 0.2.1 → 0.3.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/.simplecov +36 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +9 -0
- data/CLAUDE.md +5 -0
- data/README.md +767 -3
- data/Rakefile +9 -4
- data/app/controllers/api_keys/keys_controller.rb +43 -11
- 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 +31 -2
- 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 +2 -7
- 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 +5 -1
- 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/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +113 -7
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +21 -0
- data/gemfiles/rails_8.0.gemfile +21 -0
- data/gemfiles/rails_8.1.gemfile +21 -0
- data/lib/api_keys/configuration.rb +77 -0
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +158 -0
- data/lib/api_keys/helpers/expiration_options.rb +131 -0
- data/lib/api_keys/helpers/token_session.rb +68 -0
- data/lib/api_keys/helpers/view_helpers.rb +216 -0
- data/lib/api_keys/models/api_key.rb +229 -17
- data/lib/api_keys/models/concerns/has_api_keys.rb +183 -3
- data/lib/api_keys/services/authenticator.rb +45 -2
- data/lib/api_keys/services/digestor.rb +6 -2
- data/lib/api_keys/tenant_resolution.rb +3 -1
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -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 +9 -0
- data/lib/generators/api_keys/templates/initializer.rb +242 -120
- metadata +24 -58
|
@@ -22,6 +22,18 @@ module ApiKeys
|
|
|
22
22
|
# JSON attributes (:scopes, :metadata) are defined in the engine initializer
|
|
23
23
|
# using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
|
|
24
24
|
|
|
25
|
+
# Override scopes setter to auto-clean blank values.
|
|
26
|
+
# This handles the common case where form checkboxes submit empty strings.
|
|
27
|
+
# Works for both create and update operations.
|
|
28
|
+
def scopes=(value)
|
|
29
|
+
cleaned = if value.is_a?(Array)
|
|
30
|
+
value.reject { |s| s.blank? }
|
|
31
|
+
else
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
super(cleaned)
|
|
35
|
+
end
|
|
36
|
+
|
|
25
37
|
# == Validations ==
|
|
26
38
|
validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
|
|
27
39
|
validates :prefix, presence: true
|
|
@@ -40,6 +52,8 @@ module ApiKeys
|
|
|
40
52
|
|
|
41
53
|
# TODO: Add validation for expires_at > Time.current if present
|
|
42
54
|
validate :expiration_date_cannot_be_in_the_past, if: :expires_at?
|
|
55
|
+
validate :within_key_type_limit, on: :create, if: -> { key_type.present? && owner.present? }
|
|
56
|
+
validate :non_revocable_keys_cannot_expire, if: -> { key_type.present? && expires_at.present? }
|
|
43
57
|
|
|
44
58
|
# TODO: Add validation for scope string format
|
|
45
59
|
# TODO: Add validation for prefix format (e.g., must end with _)
|
|
@@ -56,11 +70,53 @@ module ApiKeys
|
|
|
56
70
|
scope :inactive, -> { revoked.or(expired) }
|
|
57
71
|
scope :for_prefix, ->(prefix) { where(prefix: prefix) }
|
|
58
72
|
scope :for_owner, ->(owner) { where(owner: owner) }
|
|
59
|
-
|
|
73
|
+
scope :for_key_type, ->(key_type) { where(key_type: key_type.to_s) }
|
|
74
|
+
scope :for_environment, ->(environment) { where(environment: environment.to_s) }
|
|
75
|
+
|
|
76
|
+
# Convenience scopes for key types
|
|
77
|
+
# .publishable returns only keys with key_type: "publishable"
|
|
78
|
+
# .secret returns keys that are NOT publishable (includes legacy keys with nil/blank key_type)
|
|
79
|
+
scope :publishable, -> { where(key_type: "publishable") }
|
|
80
|
+
scope :secret, -> { where.not(key_type: "publishable") }
|
|
81
|
+
|
|
82
|
+
# === Usage Analytics Scopes ===
|
|
83
|
+
# These scopes help admin dashboards analyze API key usage patterns.
|
|
84
|
+
# Useful for identifying unused keys, high-traffic keys, and stale keys that may need cleanup.
|
|
85
|
+
|
|
86
|
+
# Keys that have never been used (last_used_at is nil)
|
|
87
|
+
scope :never_used, -> { where(last_used_at: nil) }
|
|
88
|
+
|
|
89
|
+
# Keys that have been used at least once
|
|
90
|
+
scope :used, -> { where.not(last_used_at: nil) }
|
|
91
|
+
|
|
92
|
+
# Order by usage count (highest first) - useful for finding most active keys
|
|
93
|
+
scope :by_requests, -> { order(requests_count: :desc) }
|
|
94
|
+
|
|
95
|
+
# Order by last used time (most recent first, nulls last)
|
|
96
|
+
# Uses NULLS LAST for PostgreSQL compatibility; SQLite sorts nulls last by default with DESC
|
|
97
|
+
scope :by_last_used, -> { order(Arel.sql("CASE WHEN last_used_at IS NULL THEN 1 ELSE 0 END, last_used_at DESC")) }
|
|
98
|
+
|
|
99
|
+
# Active keys that haven't been used within the specified period.
|
|
100
|
+
# Useful for identifying keys that may have been abandoned or forgotten.
|
|
101
|
+
# Excludes revoked/expired keys since those are already inactive.
|
|
102
|
+
# @param period [ActiveSupport::Duration] The inactivity threshold (default: 30 days)
|
|
103
|
+
scope :stale, ->(period = 30.days) {
|
|
104
|
+
active.where("last_used_at < :threshold OR last_used_at IS NULL", threshold: period.ago)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Aliases for common admin dashboard naming conventions
|
|
108
|
+
class << self
|
|
109
|
+
alias_method :most_used, :by_requests
|
|
110
|
+
alias_method :recently_used, :by_last_used
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Convenience scope for 30-day stale keys (common admin filter)
|
|
114
|
+
scope :inactive_for_30_days, -> { stale(30.days) }
|
|
60
115
|
|
|
61
116
|
# == Instance Methods ==
|
|
62
117
|
|
|
63
118
|
def revoke!
|
|
119
|
+
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
64
120
|
update!(revoked_at: Time.current)
|
|
65
121
|
end
|
|
66
122
|
|
|
@@ -76,14 +132,91 @@ module ApiKeys
|
|
|
76
132
|
!revoked? && !expired?
|
|
77
133
|
end
|
|
78
134
|
|
|
135
|
+
# Returns true if this key can be revoked/destroyed
|
|
136
|
+
# Keys without a key_type (legacy) are always revocable
|
|
137
|
+
# Keys with a key_type check the configuration
|
|
138
|
+
def revocable?
|
|
139
|
+
return true if key_type.blank?
|
|
140
|
+
config = key_type_config
|
|
141
|
+
return true if config.nil?
|
|
142
|
+
config.fetch(:revocable, true)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns the configuration hash for this key's type
|
|
146
|
+
def key_type_config
|
|
147
|
+
return nil if key_type.blank?
|
|
148
|
+
ApiKeys.configuration.key_types&.dig(key_type.to_sym)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns the configuration hash for this key's environment
|
|
152
|
+
def environment_config
|
|
153
|
+
return nil if environment.blank?
|
|
154
|
+
ApiKeys.configuration.environments&.dig(environment.to_sym)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns true if this key type is configured as public AND non-revocable.
|
|
158
|
+
# Only these keys have their plaintext token stored in metadata for later viewing.
|
|
159
|
+
# This is used for publishable keys that are designed to be embedded in distributed apps.
|
|
160
|
+
def public_key_type?
|
|
161
|
+
return false if key_type.blank?
|
|
162
|
+
config = key_type_config
|
|
163
|
+
return false if config.nil?
|
|
164
|
+
config[:public] == true && config[:revocable] == false
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns the stored plaintext token for public, non-revocable keys.
|
|
168
|
+
# Returns nil for all other key types (the token is only available at creation time).
|
|
169
|
+
# @return [String, nil] The full plaintext token, or nil if not stored
|
|
170
|
+
def viewable_token
|
|
171
|
+
return nil unless public_key_type?
|
|
172
|
+
metadata&.dig("token")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Override destroy to prevent destroying non-revocable keys
|
|
176
|
+
def destroy
|
|
177
|
+
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
178
|
+
super
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def destroy!
|
|
182
|
+
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
183
|
+
super
|
|
184
|
+
end
|
|
185
|
+
|
|
79
186
|
# Basic scope check. Assumes scopes are stored as an array of strings.
|
|
80
|
-
#
|
|
187
|
+
#
|
|
188
|
+
# Behavior depends on whether key_types mode is enabled:
|
|
189
|
+
# - Simple mode (no key_types): blank scopes means "unrestricted" (all scopes allowed).
|
|
190
|
+
# This preserves backwards compatibility for apps that don't use scopes at all.
|
|
191
|
+
# - Key types mode: blank scopes means "no permissions". When you've configured
|
|
192
|
+
# key types with permission ceilings, an empty scope list should deny access,
|
|
193
|
+
# not silently bypass the entire permission system.
|
|
81
194
|
def allows_scope?(required_scope)
|
|
82
|
-
# Type casting for scopes/metadata happens via the attribute definition in the engine.
|
|
83
|
-
# Ensure the attribute is loaded/defined before using it.
|
|
84
|
-
# Check if the attribute method exists before calling .blank? or .include?
|
|
85
195
|
return true unless respond_to?(:scopes) # Guard clause if loaded before attribute definition
|
|
86
|
-
|
|
196
|
+
|
|
197
|
+
if scopes.blank?
|
|
198
|
+
# In key_types mode, blank scopes = no access (deny by default)
|
|
199
|
+
# In simple mode, blank scopes = unrestricted (allow by default)
|
|
200
|
+
return !ApiKeys.configuration.key_types.present?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
scopes.include?(required_scope.to_s)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Alias for scopes - provides a more user-friendly API that matches
|
|
207
|
+
# the configuration DSL where key types use `permissions` for scope ceiling.
|
|
208
|
+
# Note: We use a method instead of alias_method because `scopes` is defined
|
|
209
|
+
# dynamically via the `attribute` API in the engine initializer.
|
|
210
|
+
# @return [Array<String>] The permissions (scopes) assigned to this key
|
|
211
|
+
def permissions
|
|
212
|
+
scopes
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Check if this key has a specific permission (alias for allows_scope?)
|
|
216
|
+
# @param required_permission [String, Symbol] The permission to check
|
|
217
|
+
# @return [Boolean] true if the key has this permission or has no restrictions
|
|
218
|
+
def allows_permission?(required_permission)
|
|
219
|
+
allows_scope?(required_permission)
|
|
87
220
|
end
|
|
88
221
|
|
|
89
222
|
# Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
|
|
@@ -108,25 +241,49 @@ module ApiKeys
|
|
|
108
241
|
def set_defaults
|
|
109
242
|
# NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
|
|
110
243
|
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
244
|
+
# If key_types feature is enabled (non-empty key_types config), use type+env prefix
|
|
245
|
+
if key_types_feature_enabled? && key_type.present?
|
|
246
|
+
self.prefix ||= build_typed_prefix
|
|
247
|
+
else
|
|
248
|
+
# Legacy behavior: use owner-specific or global config prefix
|
|
249
|
+
owner_prefix_config = nil
|
|
250
|
+
if owner.present? && owner.class.respond_to?(:api_keys_settings)
|
|
251
|
+
owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
|
|
252
|
+
end
|
|
117
253
|
|
|
118
|
-
|
|
119
|
-
|
|
254
|
+
# Use owner setting if present, otherwise fall back to global config
|
|
255
|
+
prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
|
|
120
256
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
257
|
+
# Evaluate the prefix config (it might be a Proc)
|
|
258
|
+
self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
|
|
259
|
+
end
|
|
124
260
|
|
|
125
261
|
# Removed default scopes logic here. It's correctly handled in the
|
|
126
262
|
# HasApiKeys#create_api_key! helper method, which is the intended
|
|
127
263
|
# way to create keys with proper default scope application.
|
|
128
264
|
end
|
|
129
265
|
|
|
266
|
+
# Build prefix from key_type and environment configuration
|
|
267
|
+
# e.g., publishable + test → "pk_test_"
|
|
268
|
+
def build_typed_prefix
|
|
269
|
+
type_config = key_type_config
|
|
270
|
+
env_config = environment_config
|
|
271
|
+
|
|
272
|
+
type_prefix = type_config&.dig(:prefix) || key_type.to_s[0..1]
|
|
273
|
+
env_segment = env_config&.dig(:prefix_segment)
|
|
274
|
+
|
|
275
|
+
if env_segment.present?
|
|
276
|
+
"#{type_prefix}_#{env_segment}_"
|
|
277
|
+
else
|
|
278
|
+
"#{type_prefix}_"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Check if key types feature is enabled
|
|
283
|
+
def key_types_feature_enabled?
|
|
284
|
+
ApiKeys.configuration.key_types.present? && ApiKeys.configuration.key_types.any?
|
|
285
|
+
end
|
|
286
|
+
|
|
130
287
|
# Generates the secure token, hashes it, and sets relevant attributes.
|
|
131
288
|
# Called before validation on create.
|
|
132
289
|
def generate_token_and_digest
|
|
@@ -160,6 +317,14 @@ module ApiKeys
|
|
|
160
317
|
if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
|
|
161
318
|
self.expires_at = ApiKeys.configuration.expire_after.from_now
|
|
162
319
|
end
|
|
320
|
+
|
|
321
|
+
# Store plaintext token in metadata for public, non-revocable keys.
|
|
322
|
+
# This allows users to view the token again in the dashboard.
|
|
323
|
+
# SECURITY: Only do this for keys explicitly configured as public: true
|
|
324
|
+
# AND revocable: false (e.g., publishable keys for distributed apps).
|
|
325
|
+
if public_key_type?
|
|
326
|
+
self.metadata = (self.metadata || {}).merge("token" => @token)
|
|
327
|
+
end
|
|
163
328
|
end
|
|
164
329
|
|
|
165
330
|
# == Validation Helpers ==
|
|
@@ -205,5 +370,52 @@ module ApiKeys
|
|
|
205
370
|
errors.add(:expires_at, "can't be in the past") if expires_at.present? && expires_at < Time.current
|
|
206
371
|
end
|
|
207
372
|
|
|
373
|
+
# Non-revocable keys cannot have expiration dates.
|
|
374
|
+
# If a key expires but cannot be revoked/deleted, the user would be stuck
|
|
375
|
+
# with a useless expired key they can't remove.
|
|
376
|
+
def non_revocable_keys_cannot_expire
|
|
377
|
+
return unless key_type.present? && expires_at.present?
|
|
378
|
+
|
|
379
|
+
config = key_type_config
|
|
380
|
+
return unless config # No config = allow (legacy behavior)
|
|
381
|
+
|
|
382
|
+
# If this key type is non-revocable, prevent setting expiration
|
|
383
|
+
if config[:revocable] == false
|
|
384
|
+
errors.add(:expires_at, "cannot be set on non-revocable keys (#{key_type} keys cannot be revoked or deleted)")
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Check if creating this key would exceed the limit for this key type/environment
|
|
389
|
+
# Uses row-level locking to prevent race conditions when multiple requests
|
|
390
|
+
# try to create keys concurrently.
|
|
391
|
+
def within_key_type_limit
|
|
392
|
+
return unless key_types_feature_enabled?
|
|
393
|
+
|
|
394
|
+
config = key_type_config
|
|
395
|
+
return unless config # No config = no limit
|
|
396
|
+
|
|
397
|
+
limit = config[:limit]
|
|
398
|
+
return unless limit # nil limit = unlimited
|
|
399
|
+
|
|
400
|
+
# Use pessimistic locking to prevent race conditions.
|
|
401
|
+
# Lock the owner's existing keys of this type/environment while counting.
|
|
402
|
+
# This ensures atomic check-then-create semantics.
|
|
403
|
+
#
|
|
404
|
+
# Note: We use .ids.size instead of .count because PostgreSQL doesn't allow
|
|
405
|
+
# FOR UPDATE with aggregate functions (COUNT). By selecting IDs with the
|
|
406
|
+
# lock and counting in Ruby, we achieve the same race condition protection.
|
|
407
|
+
existing_count = owner.api_keys
|
|
408
|
+
.active
|
|
409
|
+
.where(key_type: key_type.to_s)
|
|
410
|
+
.where(environment: environment.to_s)
|
|
411
|
+
.lock(true)
|
|
412
|
+
.ids
|
|
413
|
+
.size
|
|
414
|
+
|
|
415
|
+
if existing_count >= limit
|
|
416
|
+
errors.add(:base, "Maximum number of #{key_type} keys (#{limit}) reached for #{environment} environment")
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
208
420
|
end
|
|
209
421
|
end
|
|
@@ -95,30 +95,135 @@ module ApiKeys
|
|
|
95
95
|
# --- Instance Methods ---
|
|
96
96
|
# Methods included in the owner model (e.g., User).
|
|
97
97
|
|
|
98
|
+
# Returns the available scopes for API keys on this owner.
|
|
99
|
+
# Uses owner-specific settings if defined, otherwise falls back to global config.
|
|
100
|
+
# Useful for populating scope checkboxes in forms.
|
|
101
|
+
#
|
|
102
|
+
# @return [Array<String>] The available scopes
|
|
103
|
+
def available_api_key_scopes
|
|
104
|
+
owner_settings = self.class.api_keys_settings
|
|
105
|
+
owner_settings&.[](:default_scopes) || ApiKeys.configuration.default_scopes || []
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Checks if this owner can create an API key of the given type.
|
|
109
|
+
# Returns false if the limit for this key type/environment is reached.
|
|
110
|
+
# Useful for conditional UI (e.g., hiding "Create" button when at limit).
|
|
111
|
+
#
|
|
112
|
+
# Note: This is a best-effort check for UI purposes without locking.
|
|
113
|
+
# Concurrent requests could see stale data. The actual limit is enforced
|
|
114
|
+
# with pessimistic locking in create_api_key!, so this is safe to use
|
|
115
|
+
# for UI decisions (worst case: button shown but creation fails validation).
|
|
116
|
+
#
|
|
117
|
+
# @param key_type [Symbol, nil] The key type to check (e.g., :publishable, :secret)
|
|
118
|
+
# @param environment [Symbol, nil] The environment to check (defaults to current_environment)
|
|
119
|
+
# @return [Boolean] true if the owner can create another key of this type
|
|
120
|
+
#
|
|
121
|
+
# @example
|
|
122
|
+
# if current_org.can_create_api_key?(key_type: :publishable)
|
|
123
|
+
# # Show "Create Publishable Key" button
|
|
124
|
+
# end
|
|
125
|
+
#
|
|
126
|
+
def can_create_api_key?(key_type: nil, environment: nil)
|
|
127
|
+
config = ApiKeys.configuration
|
|
128
|
+
|
|
129
|
+
# If no key_type specified, check global quota only
|
|
130
|
+
return within_global_quota? if key_type.nil?
|
|
131
|
+
|
|
132
|
+
# Resolve environment
|
|
133
|
+
resolved_environment = resolve_environment(environment, key_type, config)
|
|
134
|
+
|
|
135
|
+
# Get type-specific limit
|
|
136
|
+
type_config = config.key_types&.dig(key_type.to_sym)
|
|
137
|
+
return within_global_quota? unless type_config
|
|
138
|
+
|
|
139
|
+
limit = type_config[:limit]
|
|
140
|
+
return within_global_quota? unless limit
|
|
141
|
+
|
|
142
|
+
# Count existing keys of this type/environment
|
|
143
|
+
existing_count = api_keys
|
|
144
|
+
.active
|
|
145
|
+
.where(key_type: key_type.to_s)
|
|
146
|
+
.where(environment: resolved_environment.to_s)
|
|
147
|
+
.count
|
|
148
|
+
|
|
149
|
+
existing_count < limit && within_global_quota?
|
|
150
|
+
end
|
|
151
|
+
|
|
98
152
|
# Creates a new API key for this owner instance and returns the ApiKey instance.
|
|
99
153
|
# Raises ActiveRecord::RecordInvalid if creation fails.
|
|
100
154
|
#
|
|
101
155
|
# @param name [String] The name for the new API key (required).
|
|
102
156
|
# @param scopes [Array<String>, nil] Scopes for the key. Defaults to owner/global settings.
|
|
157
|
+
# When key_type is specified, scopes are filtered to only include those allowed
|
|
158
|
+
# by the key type's permissions ceiling. Blank values are automatically removed.
|
|
103
159
|
# @param expires_at [Time, nil] Optional expiration timestamp.
|
|
160
|
+
# @param expires_at_preset [String, nil] Convenience param: "7_days", "30_days", "no_expiration", etc.
|
|
161
|
+
# If provided, this is parsed into expires_at. Takes precedence over expires_at if both given.
|
|
104
162
|
# @param metadata [Hash, nil] Optional metadata hash.
|
|
163
|
+
# @param key_type [Symbol, nil] The key type (e.g., :publishable, :secret).
|
|
164
|
+
# Must be defined in ApiKeys.configuration.key_types if provided.
|
|
165
|
+
# @param environment [Symbol, nil] The environment (e.g., :test, :live).
|
|
166
|
+
# Defaults to current_environment if key_types feature is enabled.
|
|
105
167
|
# @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
|
|
106
168
|
# is available via the `#token` attribute on this instance
|
|
107
169
|
# *only until it's reloaded*.
|
|
108
|
-
def create_api_key!(name: nil, scopes: nil, expires_at: nil, metadata: nil)
|
|
170
|
+
def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil, key_type: nil, environment: nil)
|
|
171
|
+
config = ApiKeys.configuration
|
|
172
|
+
|
|
173
|
+
# Parse expires_at_preset if provided (takes precedence over expires_at)
|
|
174
|
+
if expires_at_preset.present?
|
|
175
|
+
expires_at = ApiKeys::Helpers::ExpirationOptions.parse(expires_at_preset)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Auto-clean scopes: remove blank values from arrays (common when using form checkboxes)
|
|
179
|
+
if scopes.is_a?(Array)
|
|
180
|
+
scopes = scopes.reject { |s| s.blank? }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check for missing columns if key_types feature is enabled
|
|
184
|
+
if key_types_feature_enabled?(config)
|
|
185
|
+
check_required_columns!
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Use default_key_type if not specified and key_types feature is enabled
|
|
189
|
+
resolved_key_type = key_type
|
|
190
|
+
if resolved_key_type.nil? && key_types_feature_enabled?(config) && config.default_key_type.present?
|
|
191
|
+
resolved_key_type = config.default_key_type
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Validate key_type if provided and key_types feature is enabled
|
|
195
|
+
if resolved_key_type.present?
|
|
196
|
+
validate_key_type!(resolved_key_type, config)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Determine environment: use provided, or default from config
|
|
200
|
+
resolved_environment = resolve_environment(environment, resolved_key_type, config)
|
|
201
|
+
|
|
202
|
+
# Validate environment if key_types feature is enabled
|
|
203
|
+
if resolved_environment.present? && key_types_feature_enabled?(config)
|
|
204
|
+
validate_environment!(resolved_environment, config)
|
|
205
|
+
end
|
|
206
|
+
|
|
109
207
|
# Fetch default scopes from this owner class's settings, falling back to global config.
|
|
110
208
|
owner_settings = self.class.api_keys_settings
|
|
111
|
-
default_scopes = owner_settings&.[](:default_scopes) ||
|
|
209
|
+
default_scopes = owner_settings&.[](:default_scopes) || config.default_scopes || []
|
|
112
210
|
|
|
113
211
|
# Use provided scopes if given, otherwise use the calculated defaults.
|
|
114
212
|
key_scopes = scopes.nil? ? default_scopes : Array(scopes)
|
|
115
213
|
|
|
214
|
+
# Filter scopes based on key type permissions ceiling
|
|
215
|
+
if resolved_key_type.present?
|
|
216
|
+
key_scopes = filter_scopes_by_permissions(key_scopes, resolved_key_type, config)
|
|
217
|
+
end
|
|
218
|
+
|
|
116
219
|
# Create the key using the association, letting AR handle owner_id/type.
|
|
117
220
|
api_key = self.api_keys.create!(
|
|
118
221
|
name: name,
|
|
119
222
|
scopes: key_scopes,
|
|
120
223
|
expires_at: expires_at,
|
|
121
|
-
metadata: metadata || {} # Ensure metadata is at least an empty hash
|
|
224
|
+
metadata: metadata || {}, # Ensure metadata is at least an empty hash
|
|
225
|
+
key_type: resolved_key_type&.to_s,
|
|
226
|
+
environment: resolved_environment&.to_s
|
|
122
227
|
# prefix, token_digest, digest_algorithm are set by ApiKey callbacks
|
|
123
228
|
)
|
|
124
229
|
|
|
@@ -127,6 +232,81 @@ module ApiKeys
|
|
|
127
232
|
api_key
|
|
128
233
|
end
|
|
129
234
|
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def within_global_quota?
|
|
238
|
+
owner_settings = self.class.api_keys_settings
|
|
239
|
+
limit = owner_settings&.[](:max_keys) || ApiKeys.configuration.default_max_keys_per_owner
|
|
240
|
+
return true unless limit
|
|
241
|
+
|
|
242
|
+
api_keys.active.count < limit
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def key_types_feature_enabled?(config)
|
|
246
|
+
config.key_types.present? && config.key_types.any?
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_key_type!(key_type, config)
|
|
250
|
+
return unless key_types_feature_enabled?(config)
|
|
251
|
+
|
|
252
|
+
valid_types = config.key_types.keys.map(&:to_sym)
|
|
253
|
+
unless valid_types.include?(key_type.to_sym)
|
|
254
|
+
raise ArgumentError, "Invalid key type '#{key_type}'. Valid types: #{valid_types.join(', ')}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_environment!(environment, config)
|
|
259
|
+
return unless config.environments.present? && config.environments.any?
|
|
260
|
+
|
|
261
|
+
valid_environments = config.environments.keys.map(&:to_sym)
|
|
262
|
+
unless valid_environments.include?(environment.to_sym)
|
|
263
|
+
raise ArgumentError, "Invalid environment '#{environment}'. Valid environments: #{valid_environments.join(', ')}"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def resolve_environment(provided_environment, key_type, config)
|
|
268
|
+
# If explicitly provided, use that
|
|
269
|
+
return provided_environment if provided_environment.present?
|
|
270
|
+
|
|
271
|
+
# If key_types feature is enabled, use current_environment
|
|
272
|
+
if key_types_feature_enabled?(config) && key_type.present?
|
|
273
|
+
env_lambda = config.current_environment
|
|
274
|
+
return env_lambda.respond_to?(:call) ? env_lambda.call : env_lambda
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def filter_scopes_by_permissions(scopes, key_type, config)
|
|
281
|
+
return scopes unless key_types_feature_enabled?(config)
|
|
282
|
+
|
|
283
|
+
type_config = config.key_types[key_type.to_sym]
|
|
284
|
+
return scopes unless type_config
|
|
285
|
+
|
|
286
|
+
permissions = type_config[:permissions]
|
|
287
|
+
|
|
288
|
+
# :all means no filtering
|
|
289
|
+
return scopes if permissions == :all
|
|
290
|
+
|
|
291
|
+
# Filter to only include scopes that are within the permissions ceiling
|
|
292
|
+
return [] if permissions.nil? || permissions.empty?
|
|
293
|
+
|
|
294
|
+
scopes.select { |scope| permissions.include?(scope.to_s) }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Check that required columns exist for key_types feature
|
|
298
|
+
# Raises MigrationRequiredError if columns are missing
|
|
299
|
+
def check_required_columns!
|
|
300
|
+
required_columns = %w[key_type environment]
|
|
301
|
+
existing_columns = ApiKeys::ApiKey.column_names
|
|
302
|
+
|
|
303
|
+
missing_columns = required_columns - existing_columns
|
|
304
|
+
|
|
305
|
+
if missing_columns.any?
|
|
306
|
+
raise ApiKeys::Errors::MigrationRequiredError.new(missing_columns: missing_columns)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
130
310
|
# Example: Check if the owner has reached their API key limit.
|
|
131
311
|
# def reached_api_key_limit?
|
|
132
312
|
# limit = self.class.api_keys_settings[:max_keys]
|
|
@@ -63,8 +63,15 @@ module ApiKeys
|
|
|
63
63
|
|
|
64
64
|
result = if api_key&.active?
|
|
65
65
|
log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
|
|
67
|
+
# Check environment isolation if enabled
|
|
68
|
+
env_check_result = check_environment_isolation(api_key, config)
|
|
69
|
+
if env_check_result
|
|
70
|
+
env_check_result # Return failure result
|
|
71
|
+
else
|
|
72
|
+
# TODO: Optionally update last_used_at and requests_count
|
|
73
|
+
Result.success(api_key)
|
|
74
|
+
end
|
|
68
75
|
elsif api_key&.revoked?
|
|
69
76
|
log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
|
|
70
77
|
Result.failure(error_code: :revoked_key, message: "API key has been revoked")
|
|
@@ -246,6 +253,42 @@ module ApiKeys
|
|
|
246
253
|
defined?(Rails) ? Rails.cache : nil
|
|
247
254
|
end
|
|
248
255
|
|
|
256
|
+
# Check if the API key's environment matches the current environment
|
|
257
|
+
# Returns a failure Result if there's a mismatch and strict isolation is enabled
|
|
258
|
+
# Returns nil if the check passes or is not applicable
|
|
259
|
+
def self.check_environment_isolation(api_key, config)
|
|
260
|
+
return nil unless config.strict_environment_isolation
|
|
261
|
+
|
|
262
|
+
# Skip check if key doesn't have an environment (legacy keys)
|
|
263
|
+
key_env = api_key.environment
|
|
264
|
+
return nil if key_env.blank?
|
|
265
|
+
|
|
266
|
+
# Get current environment
|
|
267
|
+
current_env_config = config.current_environment
|
|
268
|
+
current_env = current_env_config.respond_to?(:call) ? current_env_config.call : current_env_config
|
|
269
|
+
|
|
270
|
+
# Normalize to string first, then check if blank
|
|
271
|
+
# This ensures consistent string comparison and prevents edge cases with empty strings
|
|
272
|
+
current_env = current_env.to_s
|
|
273
|
+
key_env = key_env.to_s
|
|
274
|
+
|
|
275
|
+
# Skip isolation check if current environment is blank (not properly configured)
|
|
276
|
+
if current_env.blank?
|
|
277
|
+
log_debug "[ApiKeys Auth] Current environment is blank, skipping environment isolation check"
|
|
278
|
+
return nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
if current_env != key_env
|
|
282
|
+
log_debug "[ApiKeys Auth] Environment mismatch: Key environment '#{key_env}' does not match current environment '#{current_env}'"
|
|
283
|
+
return Result.failure(
|
|
284
|
+
error_code: :environment_mismatch,
|
|
285
|
+
message: "API key environment (#{key_env}) does not match current environment (#{current_env})"
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
nil # Check passed
|
|
290
|
+
end
|
|
291
|
+
|
|
249
292
|
# NOTE: Removing the incorrect private `find_key_by_token` method.
|
|
250
293
|
# def find_key_by_token(token)
|
|
251
294
|
# ...
|
|
@@ -55,12 +55,16 @@ module ApiKeys
|
|
|
55
55
|
comparison_proc.call(stored_digest, Digest::SHA256.hexdigest(token))
|
|
56
56
|
else
|
|
57
57
|
# Strategy mismatch or unsupported strategy should fail comparison safely
|
|
58
|
-
Rails
|
|
58
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
59
|
+
Rails.logger.error "[ApiKeys] Digestor comparison failed: Unsupported hash strategy '#{strategy}' for digest check."
|
|
60
|
+
end
|
|
59
61
|
false
|
|
60
62
|
end
|
|
61
63
|
rescue ArgumentError => e
|
|
62
64
|
# Catch potential errors from Digest or comparison proc
|
|
63
|
-
Rails
|
|
65
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
66
|
+
Rails.logger.error "[ApiKeys] Digestor comparison error: #{e.message}"
|
|
67
|
+
end
|
|
64
68
|
false
|
|
65
69
|
end
|
|
66
70
|
end
|
|
@@ -33,7 +33,9 @@ module ApiKeys
|
|
|
33
33
|
@current_api_tenant = resolver&.call(current_api_key)
|
|
34
34
|
rescue StandardError => e
|
|
35
35
|
# Log error but don't break the request if resolver fails
|
|
36
|
-
Rails
|
|
36
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
37
|
+
Rails.logger.error "[ApiKeys] Tenant resolution failed: #{e.message}"
|
|
38
|
+
end
|
|
37
39
|
@current_api_tenant = nil
|
|
38
40
|
end
|
|
39
41
|
end
|
data/lib/api_keys/version.rb
CHANGED
data/lib/api_keys.rb
CHANGED
|
@@ -53,12 +53,24 @@ end
|
|
|
53
53
|
|
|
54
54
|
require "api_keys/version"
|
|
55
55
|
require "api_keys/configuration" # Defines the ApiKeys::Configuration class
|
|
56
|
+
require "api_keys/errors" # Error classes for key types feature
|
|
56
57
|
|
|
57
58
|
# Files that might depend on ApiKeys.configuration being available
|
|
58
59
|
require "api_keys/controller" # This can lead to loading jobs, etc.
|
|
59
60
|
require "api_keys/models/concerns/has_api_keys"
|
|
60
61
|
require "api_keys/models/api_key"
|
|
61
62
|
|
|
63
|
+
# Helpers for integrators building custom UIs
|
|
64
|
+
require "api_keys/helpers/token_session"
|
|
65
|
+
require "api_keys/helpers/expiration_options"
|
|
66
|
+
require "api_keys/helpers/view_helpers"
|
|
67
|
+
require "api_keys/form_builder_extensions"
|
|
68
|
+
|
|
69
|
+
# Top-level aliases for cleaner API (ApiKeys::TokenSession instead of ApiKeys::Helpers::TokenSession)
|
|
70
|
+
ApiKeys::TokenSession = ApiKeys::Helpers::TokenSession
|
|
71
|
+
ApiKeys::ExpirationOptions = ApiKeys::Helpers::ExpirationOptions
|
|
72
|
+
ApiKeys::ViewHelpers = ApiKeys::Helpers::ViewHelpers
|
|
73
|
+
|
|
62
74
|
# Rails integration (Engine)
|
|
63
75
|
# The Engine might also access ApiKeys.configuration during its initialization.
|
|
64
76
|
require "api_keys/engine" if defined?(Rails)
|