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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. data/Rakefile +0 -32
@@ -10,6 +10,50 @@ module ApiKeys
10
10
  module HasApiKeys
11
11
  extend ActiveSupport::Concern
12
12
 
13
+ SUPPORTED_SETTINGS = %i[max_keys require_name default_scopes].freeze
14
+ MAX_DEFAULT_SCOPES = 100
15
+ MAX_SCOPE_BYTESIZE = 128
16
+
17
+ class << self
18
+ def validate_and_freeze_settings(settings)
19
+ max_keys = settings[:max_keys]
20
+ unless max_keys.nil? || (max_keys.is_a?(Integer) && max_keys >= 0)
21
+ raise ArgumentError, "max_keys must be a non-negative Integer or nil"
22
+ end
23
+
24
+ require_name = settings[:require_name]
25
+ unless require_name == true || require_name == false
26
+ raise ArgumentError, "require_name must be true or false"
27
+ end
28
+
29
+ scopes = settings[:default_scopes]
30
+ unless scopes.is_a?(Array) && scopes.length <= MAX_DEFAULT_SCOPES
31
+ raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
32
+ end
33
+
34
+ normalized_scopes = scopes.map { |scope| scope.is_a?(Symbol) ? scope.to_s : scope }
35
+ unless normalized_scopes.all? { |scope| valid_scope_name?(scope) }
36
+ raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
37
+ end
38
+
39
+ {
40
+ max_keys: max_keys,
41
+ require_name: require_name,
42
+ default_scopes: normalized_scopes.uniq.map { |scope| scope.dup.freeze }.freeze
43
+ }.freeze
44
+ end
45
+
46
+ private
47
+
48
+ def valid_scope_name?(scope)
49
+ scope.is_a?(String) && scope.present? && scope.valid_encoding? &&
50
+ scope.bytesize <= MAX_SCOPE_BYTESIZE &&
51
+ scope.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+ end
56
+
13
57
  # Module containing class methods to be extended onto ActiveRecord::Base
14
58
  module ClassMethods
15
59
  # Defines the association and allows configuration for the specific owner model.
@@ -27,20 +71,9 @@ module ApiKeys
27
71
  # end
28
72
  # end
29
73
  def has_api_keys(**options, &block)
30
- # Include the concern's instance methods into the calling class (e.g., User)
31
- # Ensures any instance-level helpers in HasApiKeys are available on the owner.
32
- include ApiKeys::Models::Concerns::HasApiKeys unless included_modules.include?(ApiKeys::Models::Concerns::HasApiKeys)
33
-
34
- # Define the core association on the specific class calling this method
35
- has_many :api_keys,
36
- class_name: "ApiKeys::ApiKey",
37
- as: :owner,
38
- dependent: :destroy # Consider :nullify based on requirements
39
-
40
- # Define class_attribute for settings if not already defined.
41
- # This ensures inheritance works correctly (subclasses get their own copy).
42
- unless respond_to?(:api_keys_settings)
43
- class_attribute :api_keys_settings, instance_writer: false, default: {}
74
+ unknown_settings = options.keys - SUPPORTED_SETTINGS
75
+ if unknown_settings.any?
76
+ raise ArgumentError, "Unknown has_api_keys setting(s): #{unknown_settings.join(', ')}"
44
77
  end
45
78
 
46
79
  # Initialize settings for this specific class, merging defaults and options
@@ -57,8 +90,30 @@ module ApiKeys
57
90
  dsl.instance_eval(&block)
58
91
  end
59
92
 
60
- # Assign the final settings hash to the class attribute for this class
61
- self.api_keys_settings = current_settings
93
+ validated_settings = HasApiKeys.validate_and_freeze_settings(current_settings)
94
+
95
+ # Include the concern's instance methods into the calling class (e.g., User)
96
+ # Ensures any instance-level helpers in HasApiKeys are available on the owner.
97
+ include ApiKeys::Models::Concerns::HasApiKeys unless included_modules.include?(ApiKeys::Models::Concerns::HasApiKeys)
98
+
99
+ # Define the core association on the specific class calling this method
100
+ has_many :api_keys,
101
+ class_name: "ApiKeys::ApiKey",
102
+ as: :owner,
103
+ # An owner deletion is an administrative lifecycle event and
104
+ # must remove every credential, including key types that users
105
+ # cannot revoke individually through the normal API.
106
+ dependent: :delete_all
107
+
108
+ # Define class_attribute for settings if not already defined.
109
+ # This ensures inheritance works correctly (subclasses get their own copy).
110
+ unless respond_to?(:api_keys_settings)
111
+ class_attribute :api_keys_settings, instance_writer: false, default: {}
112
+ end
113
+
114
+ # Assign an immutable copy so later mutations cannot silently change
115
+ # quota, naming, or permission policy at runtime.
116
+ self.api_keys_settings = validated_settings
62
117
 
63
118
  # TODO: Add validation hook to check key limit on create?
64
119
  # validates_with ApiKeys::Validators::MaxKeysValidator, on: :create, if: -> { api_keys_settings[:max_keys].present? }
@@ -95,38 +150,226 @@ module ApiKeys
95
150
  # --- Instance Methods ---
96
151
  # Methods included in the owner model (e.g., User).
97
152
 
153
+ # Returns the available scopes for API keys on this owner.
154
+ # Uses owner-specific settings if defined, otherwise falls back to global config.
155
+ # Useful for populating scope checkboxes in forms.
156
+ #
157
+ # @return [Array<String>] The available scopes
158
+ def available_api_key_scopes
159
+ owner_settings = self.class.api_keys_settings
160
+ owner_settings&.[](:default_scopes) || ApiKeys.configuration.default_scopes || []
161
+ end
162
+
163
+ # Checks if this owner can create an API key of the given type.
164
+ # Returns false if the limit for this key type/environment is reached.
165
+ # Useful for conditional UI (e.g., hiding "Create" button when at limit).
166
+ #
167
+ # Note: This is a best-effort check for UI purposes without locking.
168
+ # Concurrent requests could see stale data. The actual limit is enforced
169
+ # with pessimistic locking in create_api_key!, so this is safe to use
170
+ # for UI decisions (worst case: button shown but creation fails validation).
171
+ #
172
+ # @param key_type [Symbol, nil] The key type to check (e.g., :publishable, :secret)
173
+ # @param environment [Symbol, nil] The environment to check (defaults to current_environment)
174
+ # @return [Boolean] true if the owner can create another key of this type
175
+ #
176
+ # @example
177
+ # if current_org.can_create_api_key?(key_type: :publishable)
178
+ # # Show "Create Publishable Key" button
179
+ # end
180
+ #
181
+ def can_create_api_key?(key_type: nil, environment: nil)
182
+ config = ApiKeys.configuration
183
+
184
+ # If no key_type specified, check global quota only
185
+ return within_global_quota? if key_type.nil?
186
+
187
+ # Resolve environment
188
+ resolved_environment = resolve_environment(environment, key_type, config)
189
+
190
+ # Get type-specific limit
191
+ type_config = config.key_types&.find { |type, _settings| type.to_s == key_type.to_s }&.last
192
+ return within_global_quota? unless type_config
193
+
194
+ limit = type_config[:limit]
195
+ return within_global_quota? unless limit
196
+
197
+ # Count existing keys of this type/environment
198
+ existing_count = api_keys
199
+ .active
200
+ .where(key_type: key_type.to_s)
201
+ .where(environment: resolved_environment.to_s)
202
+ .count
203
+
204
+ existing_count < limit && within_global_quota?
205
+ end
206
+
98
207
  # Creates a new API key for this owner instance and returns the ApiKey instance.
99
208
  # Raises ActiveRecord::RecordInvalid if creation fails.
100
209
  #
101
210
  # @param name [String] The name for the new API key (required).
102
211
  # @param scopes [Array<String>, nil] Scopes for the key. Defaults to owner/global settings.
212
+ # When key_type is specified, scopes are filtered to only include those allowed
213
+ # by the key type's permissions ceiling. Blank values are automatically removed.
103
214
  # @param expires_at [Time, nil] Optional expiration timestamp.
215
+ # @param expires_at_preset [String, nil] Convenience param: "7_days", "30_days", "no_expiration", etc.
216
+ # If provided, this is parsed into expires_at. Takes precedence over expires_at if both given.
104
217
  # @param metadata [Hash, nil] Optional metadata hash.
218
+ # @param key_type [Symbol, nil] The key type (e.g., :publishable, :secret).
219
+ # Must be defined in ApiKeys.configuration.key_types if provided.
220
+ # @param environment [Symbol, nil] The environment (e.g., :test, :live).
221
+ # Defaults to current_environment if key_types feature is enabled.
105
222
  # @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
106
223
  # is available via the `#token` attribute on this instance
107
224
  # *only until it's reloaded*.
108
- def create_api_key!(name: nil, scopes: nil, expires_at: nil, metadata: nil)
225
+ def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil, key_type: nil, environment: nil)
226
+ config = ApiKeys.configuration
227
+
228
+ # Parse expires_at_preset if provided (takes precedence over expires_at)
229
+ if expires_at_preset.present?
230
+ expires_at = ApiKeys::Helpers::ExpirationOptions.parse(expires_at_preset)
231
+ end
232
+
233
+ # Auto-clean scopes: remove blank values from arrays (common when using form checkboxes)
234
+ if scopes.is_a?(Array)
235
+ scopes = scopes.reject { |s| s.blank? }
236
+ end
237
+
238
+ # Check for missing columns if key_types feature is enabled
239
+ if key_types_feature_enabled?(config)
240
+ check_required_columns!
241
+ end
242
+
243
+ # Use default_key_type if not specified and key_types feature is enabled
244
+ resolved_key_type = key_type
245
+ if resolved_key_type.nil? && key_types_feature_enabled?(config) && config.default_key_type.present?
246
+ resolved_key_type = config.default_key_type
247
+ end
248
+
249
+ # Validate key_type if provided and key_types feature is enabled
250
+ if resolved_key_type.present?
251
+ validate_key_type!(resolved_key_type, config)
252
+ elsif key_types_feature_enabled?(config)
253
+ raise ArgumentError, "key_type is required when key types are configured"
254
+ end
255
+
256
+ # Determine environment: use provided, or default from config
257
+ resolved_environment = resolve_environment(environment, resolved_key_type, config)
258
+
259
+ # Validate environment if key_types feature is enabled
260
+ if resolved_environment.present? && key_types_feature_enabled?(config)
261
+ validate_environment!(resolved_environment, config)
262
+ end
263
+
109
264
  # Fetch default scopes from this owner class's settings, falling back to global config.
110
265
  owner_settings = self.class.api_keys_settings
111
- default_scopes = owner_settings&.[](:default_scopes) || ApiKeys.configuration.default_scopes || []
266
+ default_scopes = owner_settings&.[](:default_scopes) || config.default_scopes || []
112
267
 
113
268
  # Use provided scopes if given, otherwise use the calculated defaults.
114
269
  key_scopes = scopes.nil? ? default_scopes : Array(scopes)
115
270
 
116
- # Create the key using the association, letting AR handle owner_id/type.
117
- api_key = self.api_keys.create!(
118
- name: name,
119
- scopes: key_scopes,
120
- expires_at: expires_at,
121
- metadata: metadata || {} # Ensure metadata is at least an empty hash
122
- # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
123
- )
271
+ # Filter scopes based on key type permissions ceiling
272
+ if resolved_key_type.present?
273
+ key_scopes = filter_scopes_by_permissions(key_scopes, resolved_key_type, config)
274
+ end
275
+
276
+ raise ArgumentError, "API key owner must be persisted before creating a key" unless persisted?
277
+
278
+ # ApiKey's creation callback locks the owner row before quota validation.
279
+ # Keep an explicit transaction here so the helper's creation workflow is
280
+ # a single atomic unit; direct ApiKey.create! calls are protected too.
281
+ api_key = self.class.transaction do
282
+ self.api_keys.create!(
283
+ name: name,
284
+ scopes: key_scopes,
285
+ expires_at: expires_at,
286
+ metadata: metadata || {}, # Ensure metadata is at least an empty hash
287
+ key_type: resolved_key_type&.to_s,
288
+ environment: resolved_environment&.to_s
289
+ # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
290
+ )
291
+ end
124
292
 
125
293
  # Return the ApiKey instance itself.
126
294
  # The plaintext token is available via `api_key.token` immediately after this.
127
295
  api_key
128
296
  end
129
297
 
298
+ private
299
+
300
+ def within_global_quota?
301
+ owner_settings = self.class.api_keys_settings
302
+ limit = owner_settings&.[](:max_keys) || ApiKeys.configuration.default_max_keys_per_owner
303
+ return true unless limit
304
+
305
+ api_keys.active.count < limit
306
+ end
307
+
308
+ def key_types_feature_enabled?(config)
309
+ config.key_types.present? && config.key_types.any?
310
+ end
311
+
312
+ def validate_key_type!(key_type, config)
313
+ return unless key_types_feature_enabled?(config)
314
+
315
+ valid_types = config.key_types.keys.map(&:to_s)
316
+ unless valid_types.include?(key_type.to_s)
317
+ raise ArgumentError, "Invalid key type '#{key_type}'. Valid types: #{valid_types.join(', ')}"
318
+ end
319
+ end
320
+
321
+ def validate_environment!(environment, config)
322
+ return unless config.environments.present? && config.environments.any?
323
+
324
+ valid_environments = config.environments.keys.map(&:to_s)
325
+ unless valid_environments.include?(environment.to_s)
326
+ raise ArgumentError, "Invalid environment '#{environment}'. Valid environments: #{valid_environments.join(', ')}"
327
+ end
328
+ end
329
+
330
+ def resolve_environment(provided_environment, key_type, config)
331
+ # If explicitly provided, use that
332
+ return provided_environment if provided_environment.present?
333
+
334
+ # If key_types feature is enabled, use current_environment
335
+ if key_types_feature_enabled?(config) && key_type.present?
336
+ env_lambda = config.current_environment
337
+ return env_lambda.respond_to?(:call) ? env_lambda.call : env_lambda
338
+ end
339
+
340
+ nil
341
+ end
342
+
343
+ def filter_scopes_by_permissions(scopes, key_type, config)
344
+ return scopes unless key_types_feature_enabled?(config)
345
+
346
+ type_config = config.key_types.find { |type, _settings| type.to_s == key_type.to_s }&.last
347
+ return scopes unless type_config
348
+
349
+ permissions = type_config[:permissions]
350
+
351
+ # :all means no filtering
352
+ return scopes if permissions == :all
353
+
354
+ # Filter to only include scopes that are within the permissions ceiling
355
+ return [] if permissions.nil? || permissions.empty?
356
+
357
+ scopes.select { |scope| permissions.include?(scope.to_s) }
358
+ end
359
+
360
+ # Check that required columns exist for key_types feature
361
+ # Raises MigrationRequiredError if columns are missing
362
+ def check_required_columns!
363
+ required_columns = %w[key_type environment]
364
+ existing_columns = ApiKeys::ApiKey.column_names
365
+
366
+ missing_columns = required_columns - existing_columns
367
+
368
+ if missing_columns.any?
369
+ raise ApiKeys::Errors::MigrationRequiredError.new(missing_columns: missing_columns)
370
+ end
371
+ end
372
+
130
373
  # Example: Check if the owner has reached their API key limit.
131
374
  # def reached_api_key_limit?
132
375
  # limit = self.class.api_keys_settings[:max_keys]