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
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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) ||
|
|
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
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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]
|