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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +36 -0
  3. data/AGENTS.md +5 -0
  4. data/Appraisals +17 -0
  5. data/CHANGELOG.md +9 -0
  6. data/CLAUDE.md +5 -0
  7. data/README.md +767 -3
  8. data/Rakefile +9 -4
  9. data/app/controllers/api_keys/keys_controller.rb +43 -11
  10. data/app/controllers/api_keys/security_controller.rb +8 -0
  11. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  12. data/app/views/api_keys/keys/_form.html.erb +31 -2
  13. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  14. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  15. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  16. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  17. data/app/views/api_keys/keys/_keys_table.html.erb +2 -7
  18. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  19. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  20. data/app/views/api_keys/keys/_show_token.html.erb +5 -1
  21. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  22. data/app/views/api_keys/keys/index.html.erb +40 -8
  23. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  24. data/app/views/layouts/api_keys/application.html.erb +113 -7
  25. data/context7.json +4 -0
  26. data/gemfiles/rails_7.2.gemfile +21 -0
  27. data/gemfiles/rails_8.0.gemfile +21 -0
  28. data/gemfiles/rails_8.1.gemfile +21 -0
  29. data/lib/api_keys/configuration.rb +77 -0
  30. data/lib/api_keys/errors.rb +73 -0
  31. data/lib/api_keys/form_builder_extensions.rb +158 -0
  32. data/lib/api_keys/helpers/expiration_options.rb +131 -0
  33. data/lib/api_keys/helpers/token_session.rb +68 -0
  34. data/lib/api_keys/helpers/view_helpers.rb +216 -0
  35. data/lib/api_keys/models/api_key.rb +229 -17
  36. data/lib/api_keys/models/concerns/has_api_keys.rb +183 -3
  37. data/lib/api_keys/services/authenticator.rb +45 -2
  38. data/lib/api_keys/services/digestor.rb +6 -2
  39. data/lib/api_keys/tenant_resolution.rb +3 -1
  40. data/lib/api_keys/version.rb +1 -1
  41. data/lib/api_keys.rb +12 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  44. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +9 -0
  45. data/lib/generators/api_keys/templates/initializer.rb +242 -120
  46. 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
- # TODO: Add more scopes as needed (e.g., for_owner)
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
- # Returns true if the key has no specific scopes (allowing all) or includes the required scope.
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
- scopes.blank? || scopes.include?(required_scope.to_s)
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
- # Determine the prefix: owner-specific setting > global config
112
- # Note: `owner` might not be set yet if called outside normal AR flow.
113
- owner_prefix_config = nil
114
- if owner.present? && owner.class.respond_to?(:api_keys_settings)
115
- owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
116
- end
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
- # Use owner setting if present, otherwise fall back to global config
119
- prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
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
- # Evaluate the prefix config (it might be a Proc)
122
- # Ensure `self.prefix` is only set if it's not already present.
123
- self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
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) || ApiKeys.configuration.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
- # TODO: Optionally update last_used_at and requests_count
67
- Result.success(api_key)
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.logger.error "[ApiKeys] Digestor comparison failed: Unsupported hash strategy '#{strategy}' for digest check." if defined?(Rails.logger)
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.logger.error "[ApiKeys] Digestor comparison error: #{e.message}" if defined?(Rails.logger)
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.logger.error "[ApiKeys] Tenant resolution failed: #{e.message}" if defined?(Rails.logger)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
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)