api_keys 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- data/Rakefile +0 -32
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/numeric/time"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
4
5
|
require "active_support/security_utils"
|
|
5
6
|
|
|
6
7
|
module ApiKeys
|
|
@@ -9,51 +10,467 @@ module ApiKeys
|
|
|
9
10
|
class Configuration
|
|
10
11
|
# Default empty callback proc
|
|
11
12
|
DEFAULT_CALLBACK = ->(_context){}.freeze
|
|
13
|
+
DEFAULT_PARENT_CONTROLLER = "::ApplicationController"
|
|
12
14
|
|
|
13
15
|
# == Accessors ==
|
|
14
16
|
|
|
15
17
|
# Core Authentication
|
|
16
|
-
|
|
18
|
+
attr_reader :header, :query_param
|
|
17
19
|
|
|
18
20
|
# Token Generation
|
|
19
|
-
|
|
21
|
+
attr_reader :token_prefix, :token_length, :token_alphabet
|
|
20
22
|
|
|
21
23
|
# Storage & Verification
|
|
22
|
-
|
|
24
|
+
attr_reader :hash_strategy
|
|
25
|
+
attr_reader :secure_compare_proc
|
|
26
|
+
attr_accessor :key_store_adapter, :policy_provider
|
|
23
27
|
|
|
24
28
|
# Engine Configuration
|
|
25
|
-
|
|
29
|
+
attr_reader :parent_controller
|
|
26
30
|
|
|
27
31
|
# Owner Context Configuration
|
|
28
|
-
|
|
32
|
+
attr_reader :current_owner_method, :authenticate_owner_method
|
|
29
33
|
|
|
30
34
|
# Optional Behaviors
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
attr_reader :default_max_keys_per_owner, :require_key_name
|
|
36
|
+
attr_reader :expire_after, :default_scopes, :track_requests_count
|
|
33
37
|
|
|
34
38
|
# Performance
|
|
35
|
-
|
|
39
|
+
attr_reader :cache_ttl, :stats_update_interval
|
|
36
40
|
|
|
37
41
|
# Security
|
|
38
|
-
|
|
42
|
+
attr_reader :https_only_production, :https_strict_mode
|
|
39
43
|
|
|
40
44
|
# Tenant Resolution
|
|
41
|
-
|
|
45
|
+
attr_reader :tenant_resolver
|
|
42
46
|
|
|
43
47
|
# Callbacks (Placeholders for future extension)
|
|
44
|
-
|
|
48
|
+
attr_reader :before_authentication, :after_authentication
|
|
45
49
|
|
|
46
50
|
# Background Job Queues
|
|
47
|
-
|
|
51
|
+
attr_reader :stats_job_queue, :callbacks_job_queue
|
|
48
52
|
|
|
49
53
|
# Global Async Toggle
|
|
50
|
-
|
|
54
|
+
attr_reader :enable_async_operations
|
|
51
55
|
|
|
52
56
|
# Engine UI Configuration
|
|
53
57
|
attr_accessor :return_url, :return_text
|
|
54
58
|
|
|
55
59
|
# Debugging
|
|
56
|
-
|
|
60
|
+
attr_reader :debug_logging
|
|
61
|
+
|
|
62
|
+
# Key Types & Environments (Stripe-style publishable/secret keys)
|
|
63
|
+
#
|
|
64
|
+
# @!attribute [rw] key_types
|
|
65
|
+
# @return [Hash] Key type definitions. Each key type has:
|
|
66
|
+
# - :prefix [String] Token prefix (e.g., "pk" → pk_test_)
|
|
67
|
+
# - :permissions [Array<String>, :all] Scope ceiling for this type
|
|
68
|
+
# - :revocable [Boolean] Whether keys can be revoked (default: true)
|
|
69
|
+
# - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
|
|
70
|
+
# - :public [Boolean] If true AND revocable: false, store plaintext token in
|
|
71
|
+
# metadata so it can be viewed again in dashboard. Use ONLY for publishable
|
|
72
|
+
# keys that are designed to be embedded in distributed apps. (default: false)
|
|
73
|
+
# @example
|
|
74
|
+
# config.key_types = {
|
|
75
|
+
# publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
|
|
76
|
+
# secret: { prefix: "sk", permissions: :all }
|
|
77
|
+
# }
|
|
78
|
+
#
|
|
79
|
+
# @!attribute [rw] environments
|
|
80
|
+
# @return [Hash] Environment definitions. Each environment has:
|
|
81
|
+
# - :prefix_segment [String, nil] Middle part of prefix (e.g., "test" → pk_test_)
|
|
82
|
+
# @example
|
|
83
|
+
# config.environments = { test: { prefix_segment: "test" }, live: { prefix_segment: "live" } }
|
|
84
|
+
#
|
|
85
|
+
# @!attribute [rw] current_environment
|
|
86
|
+
# @return [Proc, Symbol] Lambda or symbol returning current environment
|
|
87
|
+
# @example
|
|
88
|
+
# config.current_environment = -> { Rails.env.production? ? :live : :test }
|
|
89
|
+
#
|
|
90
|
+
# @!attribute [rw] strict_environment_isolation
|
|
91
|
+
# @return [Boolean] If true, keys only work in their matching environment
|
|
92
|
+
#
|
|
93
|
+
# @!attribute [rw] default_key_type
|
|
94
|
+
# @return [Symbol, nil] Default key type when not specified in create_api_key!
|
|
95
|
+
# @example
|
|
96
|
+
# config.default_key_type = :secret
|
|
97
|
+
#
|
|
98
|
+
# @!attribute [rw] dashboard_allow_cross_environment
|
|
99
|
+
# @return [Boolean] If true, dashboard shows keys from all environments.
|
|
100
|
+
# If false (default), dashboard only shows keys matching current_environment.
|
|
101
|
+
# @example
|
|
102
|
+
# config.dashboard_allow_cross_environment = false
|
|
103
|
+
attr_reader :environments, :current_environment, :strict_environment_isolation,
|
|
104
|
+
:default_key_type, :dashboard_allow_cross_environment
|
|
105
|
+
|
|
106
|
+
# Custom writer for key_types that validates prefix uniqueness
|
|
107
|
+
attr_reader :key_types
|
|
108
|
+
|
|
109
|
+
VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
|
|
110
|
+
VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
|
|
111
|
+
TOKEN_LENGTH_RANGE = (16..64)
|
|
112
|
+
MAX_CONFIGURED_SCOPES = 100
|
|
113
|
+
CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
114
|
+
HTTP_HEADER_PATTERN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}\z/
|
|
115
|
+
QUERY_PARAM_PATTERN = /\A[a-zA-Z0-9_.~-]{1,128}\z/
|
|
116
|
+
METHOD_NAME_PATTERN = /\A[a-zA-Z_]\w*[!?]?\z/
|
|
117
|
+
CONSTANT_NAME_PATTERN = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
118
|
+
BOOLEAN_SETTINGS = %i[
|
|
119
|
+
require_key_name track_requests_count https_only_production https_strict_mode
|
|
120
|
+
enable_async_operations debug_logging strict_environment_isolation
|
|
121
|
+
dashboard_allow_cross_environment
|
|
122
|
+
].freeze
|
|
123
|
+
|
|
124
|
+
def header=(value)
|
|
125
|
+
unless value.nil? || (value.is_a?(String) && value.match?(HTTP_HEADER_PATTERN))
|
|
126
|
+
raise ArgumentError, "header must be nil or a valid HTTP header name of at most 128 characters"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@header = value&.dup&.freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def query_param=(value)
|
|
133
|
+
unless value.nil? || (value.is_a?(String) && value.match?(QUERY_PARAM_PATTERN))
|
|
134
|
+
raise ArgumentError, "query_param must be nil or a safe parameter name of at most 128 characters"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@query_param = value&.dup&.freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def default_max_keys_per_owner=(value)
|
|
141
|
+
unless value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
142
|
+
raise ArgumentError, "default_max_keys_per_owner must be a non-negative Integer or nil"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@default_max_keys_per_owner = value
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def expire_after=(value)
|
|
149
|
+
unless value.nil?
|
|
150
|
+
seconds = value.to_f if value.respond_to?(:to_f)
|
|
151
|
+
valid = value.respond_to?(:from_now) && seconds&.finite? && seconds.positive?
|
|
152
|
+
raise ArgumentError, "expire_after must be a positive duration or nil" unless valid
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@expire_after = value
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def default_scopes=(value)
|
|
159
|
+
unless value.is_a?(Array) && value.length <= MAX_CONFIGURED_SCOPES && value.all? { |scope| valid_scope_name?(scope) }
|
|
160
|
+
raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@default_scopes = deep_copy_and_freeze(value.uniq)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cache_ttl=(value)
|
|
167
|
+
unless value.nil?
|
|
168
|
+
seconds = value.to_f if value.respond_to?(:to_f)
|
|
169
|
+
valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
|
|
170
|
+
raise ArgumentError, "cache_ttl must be a finite non-negative duration/number or nil" unless valid
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@cache_ttl = value
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def stats_update_interval=(value)
|
|
177
|
+
unless value.nil?
|
|
178
|
+
seconds = value.to_f if value.respond_to?(:to_f)
|
|
179
|
+
valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
|
|
180
|
+
raise ArgumentError, "stats_update_interval must be a finite non-negative duration/number or nil" unless valid
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
@stats_update_interval = value
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parent_controller=(value)
|
|
187
|
+
valid = value.is_a?(Class) || (value.is_a?(String) && value.match?(CONSTANT_NAME_PATTERN))
|
|
188
|
+
raise ArgumentError, "parent_controller must be a controller Class or a valid constant name" unless valid
|
|
189
|
+
|
|
190
|
+
@parent_controller = value.is_a?(String) ? value.dup.freeze : value
|
|
191
|
+
@parent_controller_explicitly_configured = true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parent_controller_class
|
|
195
|
+
candidate = if !@parent_controller_explicitly_configured && defined?(ApiKeys::Engine) &&
|
|
196
|
+
ApiKeys::Engine.config.parent_controller.present?
|
|
197
|
+
ApiKeys::Engine.config.parent_controller
|
|
198
|
+
else
|
|
199
|
+
parent_controller
|
|
200
|
+
end
|
|
201
|
+
candidate.is_a?(Class) ? candidate : candidate.constantize
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
BOOLEAN_SETTINGS.each do |setting|
|
|
205
|
+
define_method("#{setting}=") do |value|
|
|
206
|
+
raise ArgumentError, "#{setting} must be true or false" unless value == true || value == false
|
|
207
|
+
|
|
208
|
+
instance_variable_set("@#{setting}", value)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def before_authentication=(value)
|
|
213
|
+
validate_callback!(value, :before_authentication)
|
|
214
|
+
@before_authentication = value
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def after_authentication=(value)
|
|
218
|
+
validate_callback!(value, :after_authentication)
|
|
219
|
+
@after_authentication = value
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def tenant_resolver=(value)
|
|
223
|
+
raise ArgumentError, "tenant_resolver must be callable" unless value.respond_to?(:call)
|
|
224
|
+
|
|
225
|
+
@tenant_resolver = value
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def secure_compare_proc=(value)
|
|
229
|
+
raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call)
|
|
230
|
+
|
|
231
|
+
@secure_compare_proc = value
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def stats_job_queue=(value)
|
|
235
|
+
@stats_job_queue = validate_queue_name!(value, :stats_job_queue)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def callbacks_job_queue=(value)
|
|
239
|
+
@callbacks_job_queue = validate_queue_name!(value, :callbacks_job_queue)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def current_owner_method=(value)
|
|
243
|
+
@current_owner_method = validate_method_name!(value, :current_owner_method)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def authenticate_owner_method=(value)
|
|
247
|
+
@authenticate_owner_method = validate_method_name!(value, :authenticate_owner_method)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def current_environment=(value)
|
|
251
|
+
unless value.nil? || value.respond_to?(:call) || valid_config_name?(value)
|
|
252
|
+
raise ArgumentError, "current_environment must be callable, a safe String/Symbol, or nil"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
@current_environment = value
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def default_key_type=(value)
|
|
259
|
+
unless value.nil? || valid_config_name?(value)
|
|
260
|
+
raise ArgumentError, "default_key_type must be a safe String/Symbol or nil"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
@default_key_type = value
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def token_prefix=(value)
|
|
267
|
+
unless value.is_a?(String) || value.respond_to?(:call)
|
|
268
|
+
raise ArgumentError, "token_prefix must be a String or callable object"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
validate_resolved_prefix!(value) if value.is_a?(String)
|
|
272
|
+
@token_prefix = value
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def resolved_token_prefix
|
|
276
|
+
value = @token_prefix.respond_to?(:call) ? @token_prefix.call : @token_prefix
|
|
277
|
+
validate_resolved_prefix!(value)
|
|
278
|
+
value
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def token_length=(value)
|
|
282
|
+
unless value.is_a?(Integer) && TOKEN_LENGTH_RANGE.cover?(value)
|
|
283
|
+
raise ArgumentError, "token_length must be an Integer between #{TOKEN_LENGTH_RANGE.begin} and #{TOKEN_LENGTH_RANGE.end}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
@token_length = value
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def token_alphabet=(value)
|
|
290
|
+
unless VALID_TOKEN_ALPHABETS.include?(value)
|
|
291
|
+
raise ArgumentError, "token_alphabet must be one of: #{VALID_TOKEN_ALPHABETS.join(', ')}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
@token_alphabet = value
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def hash_strategy=(value)
|
|
298
|
+
unless VALID_HASH_STRATEGIES.include?(value)
|
|
299
|
+
raise ArgumentError, "hash_strategy must be one of: #{VALID_HASH_STRATEGIES.join(', ')}"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
@hash_strategy = value
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Sets the key types configuration with prefix collision validation.
|
|
306
|
+
# @param value [Hash] Key type definitions
|
|
307
|
+
# @raise [ArgumentError] If multiple key types share the same prefix
|
|
308
|
+
def key_types=(value)
|
|
309
|
+
raise ArgumentError, "key_types must be a Hash" unless value.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
validate_key_types!(value)
|
|
312
|
+
validate_composite_prefixes!(value, @environments || {})
|
|
313
|
+
@key_types = deep_copy_and_freeze(value)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def environments=(value)
|
|
317
|
+
raise ArgumentError, "environments must be a Hash" unless value.is_a?(Hash)
|
|
318
|
+
|
|
319
|
+
value.each do |name, environment_config|
|
|
320
|
+
validate_config_name!(name, "environment")
|
|
321
|
+
raise ArgumentError, "Environment '#{name}' configuration must be a Hash" unless environment_config.is_a?(Hash)
|
|
322
|
+
|
|
323
|
+
segment = environment_config[:prefix_segment]
|
|
324
|
+
validate_config_name!(segment, "environment prefix segment") unless segment.nil?
|
|
325
|
+
end
|
|
326
|
+
validate_duplicate_config_names!(value, "environment")
|
|
327
|
+
validate_composite_prefixes!(@key_types || {}, value)
|
|
328
|
+
@environments = deep_copy_and_freeze(value)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
def validate_resolved_prefix!(prefix)
|
|
334
|
+
valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
|
|
335
|
+
prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
336
|
+
return if valid
|
|
337
|
+
|
|
338
|
+
raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
|
|
339
|
+
rescue ArgumentError
|
|
340
|
+
raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def validate_key_types!(key_types_hash)
|
|
344
|
+
validate_duplicate_config_names!(key_types_hash, "key type")
|
|
345
|
+
|
|
346
|
+
key_types_hash.each do |name, type_config|
|
|
347
|
+
validate_config_name!(name, "key type")
|
|
348
|
+
raise ArgumentError, "Key type '#{name}' configuration must be a Hash" unless type_config.is_a?(Hash)
|
|
349
|
+
|
|
350
|
+
validate_config_name!(type_config[:prefix], "key type prefix")
|
|
351
|
+
permissions = type_config[:permissions]
|
|
352
|
+
unless permissions == :all || permissions.is_a?(Array)
|
|
353
|
+
raise ArgumentError, "Key type '#{name}' permissions must be :all or an Array of strings"
|
|
354
|
+
end
|
|
355
|
+
if permissions.is_a?(Array) && permissions.length > MAX_CONFIGURED_SCOPES
|
|
356
|
+
raise ArgumentError, "Key type '#{name}' permissions cannot contain more than #{MAX_CONFIGURED_SCOPES} entries"
|
|
357
|
+
end
|
|
358
|
+
if permissions.is_a?(Array) && permissions.any? { |permission| !valid_scope_name?(permission) }
|
|
359
|
+
raise ArgumentError, "Key type '#{name}' permissions must contain only non-blank strings of at most 128 bytes"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
%i[revocable public].each do |setting|
|
|
363
|
+
next unless type_config.key?(setting)
|
|
364
|
+
next if [true, false].include?(type_config[setting])
|
|
365
|
+
|
|
366
|
+
raise ArgumentError, "Key type '#{name}' #{setting} must be true or false"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
limit = type_config[:limit]
|
|
370
|
+
if !limit.nil? && (!limit.is_a?(Integer) || limit <= 0)
|
|
371
|
+
raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
next unless type_config[:public] == true
|
|
375
|
+
|
|
376
|
+
unless type_config[:revocable] == false
|
|
377
|
+
raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false"
|
|
378
|
+
end
|
|
379
|
+
unless permissions.is_a?(Array) && permissions.any?
|
|
380
|
+
raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list"
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
validate_key_type_prefixes!(key_types_hash)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def validate_config_name!(value, label)
|
|
388
|
+
return if valid_config_name?(value)
|
|
389
|
+
|
|
390
|
+
raise ArgumentError, "#{label} must contain only letters, numbers, underscores, or hyphens (1-64 characters)"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def valid_config_name?(value)
|
|
394
|
+
(value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(CONFIG_NAME_PATTERN)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def valid_scope_name?(value)
|
|
398
|
+
value.is_a?(String) && value.present? && value.valid_encoding? && value.bytesize <= 128 &&
|
|
399
|
+
value.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
400
|
+
rescue ArgumentError
|
|
401
|
+
false
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Validates that all key type prefixes are unique to prevent token collision
|
|
405
|
+
def validate_key_type_prefixes!(key_types_hash)
|
|
406
|
+
prefixes = key_types_hash.map { |_type, config| config[:prefix].to_s }.compact
|
|
407
|
+
duplicates = prefixes.group_by(&:itself).select { |_k, v| v.size > 1 }.keys
|
|
408
|
+
|
|
409
|
+
if duplicates.any?
|
|
410
|
+
raise ArgumentError, "Key type prefixes must be unique. Duplicate prefix(es): #{duplicates.join(', ')}"
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def validate_duplicate_config_names!(configuration, label)
|
|
415
|
+
duplicates = configuration.keys.map(&:to_s).group_by(&:itself).select { |_name, names| names.length > 1 }.keys
|
|
416
|
+
return if duplicates.empty?
|
|
417
|
+
|
|
418
|
+
raise ArgumentError, "#{label} names must be unique after string normalization: #{duplicates.join(', ')}"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def validate_composite_prefixes!(key_types, environments)
|
|
422
|
+
return if key_types.empty?
|
|
423
|
+
|
|
424
|
+
environment_entries = environments.empty? ? [[nil, {}]] : environments.to_a
|
|
425
|
+
combinations = key_types.flat_map do |type_name, type_config|
|
|
426
|
+
environment_entries.map do |environment_name, environment_config|
|
|
427
|
+
segment = environment_config[:prefix_segment]
|
|
428
|
+
prefix = segment.nil? ? "#{type_config[:prefix]}_" : "#{type_config[:prefix]}_#{segment}_"
|
|
429
|
+
[prefix, "#{type_name}/#{environment_name || 'default'}"]
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
collisions = combinations.group_by(&:first).select { |_prefix, entries| entries.length > 1 }
|
|
433
|
+
return if collisions.empty?
|
|
434
|
+
|
|
435
|
+
details = collisions.map { |prefix, entries| "#{prefix} (#{entries.map(&:last).join(', ')})" }.join("; ")
|
|
436
|
+
raise ArgumentError, "Key type/environment prefixes must be unique: #{details}"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def validate_callback!(value, setting)
|
|
440
|
+
raise ArgumentError, "#{setting} must be a Proc" unless value.is_a?(Proc)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def validate_queue_name!(value, setting)
|
|
444
|
+
unless (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(/\A[a-zA-Z0-9_-]{1,128}\z/)
|
|
445
|
+
raise ArgumentError, "#{setting} must be a safe String or Symbol"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
value
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def validate_method_name!(value, setting)
|
|
452
|
+
unless value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(METHOD_NAME_PATTERN))
|
|
453
|
+
raise ArgumentError, "#{setting} must be a valid method name or nil"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
value
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def deep_copy_and_freeze(value)
|
|
460
|
+
copied = case value
|
|
461
|
+
when Hash
|
|
462
|
+
value.to_h { |key, nested_value| [deep_copy_and_freeze(key), deep_copy_and_freeze(nested_value)] }
|
|
463
|
+
when Array
|
|
464
|
+
value.map { |nested_value| deep_copy_and_freeze(nested_value) }
|
|
465
|
+
when String
|
|
466
|
+
value.dup
|
|
467
|
+
else
|
|
468
|
+
value
|
|
469
|
+
end
|
|
470
|
+
copied.freeze
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
public
|
|
57
474
|
|
|
58
475
|
# == Initialization ==
|
|
59
476
|
|
|
@@ -85,7 +502,8 @@ module ApiKeys
|
|
|
85
502
|
@policy_provider = "ApiKeys::BasePolicy" # Default authorization policy class name
|
|
86
503
|
|
|
87
504
|
# Engine Configuration
|
|
88
|
-
@parent_controller =
|
|
505
|
+
@parent_controller = DEFAULT_PARENT_CONTROLLER
|
|
506
|
+
@parent_controller_explicitly_configured = false
|
|
89
507
|
|
|
90
508
|
# Owner Context Configuration
|
|
91
509
|
@current_owner_method = :current_user # Default to current_user for backward compatibility
|
|
@@ -95,14 +513,15 @@ module ApiKeys
|
|
|
95
513
|
@default_max_keys_per_owner = nil # No global key limit per owner
|
|
96
514
|
@require_key_name = false # Don't require names for keys globally
|
|
97
515
|
@expire_after = nil # Keys do not expire by default (e.g., 90.days)
|
|
98
|
-
@default_scopes = [] # No default scopes assigned globally
|
|
516
|
+
@default_scopes = [].freeze # No default scopes assigned globally
|
|
99
517
|
|
|
100
518
|
# Performance
|
|
101
519
|
@cache_ttl = 5.seconds # Good balance: fast revocation, mostly-cached – still allows most repeated requests to benefit from cache
|
|
520
|
+
@stats_update_interval = 1.minute # Debounce last_used_at writes unless exact request counting is enabled
|
|
102
521
|
|
|
103
522
|
# Security
|
|
104
523
|
@https_only_production = true # Warn if used over HTTP in production
|
|
105
|
-
@https_strict_mode =
|
|
524
|
+
@https_strict_mode = true # Fail closed if a production request is not HTTPS
|
|
106
525
|
|
|
107
526
|
# Background Job Queues
|
|
108
527
|
@stats_job_queue = :default
|
|
@@ -127,6 +546,14 @@ module ApiKeys
|
|
|
127
546
|
|
|
128
547
|
# Tenant Resolution
|
|
129
548
|
@tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
|
|
549
|
+
|
|
550
|
+
# Key Types & Environments (default to empty/disabled for backwards compatibility)
|
|
551
|
+
@key_types = {}.freeze # Empty = feature disabled, legacy behavior
|
|
552
|
+
@environments = {}.freeze # Empty = no environment-based prefixes
|
|
553
|
+
@current_environment = -> { :default } # Default environment detection
|
|
554
|
+
@strict_environment_isolation = false # Don't enforce environment isolation by default
|
|
555
|
+
@default_key_type = nil # No default key type (must be specified explicitly)
|
|
556
|
+
@dashboard_allow_cross_environment = false # Dashboard shows only current environment's keys
|
|
130
557
|
end
|
|
131
558
|
end
|
|
132
559
|
end
|
data/lib/api_keys/engine.rb
CHANGED
|
@@ -8,20 +8,11 @@ module ApiKeys
|
|
|
8
8
|
class Engine < ::Rails::Engine
|
|
9
9
|
isolate_namespace ApiKeys
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
config.autoload_paths << File.expand_path("../models", __dir__)
|
|
17
|
-
config.autoload_paths << File.expand_path("../models/concerns", __dir__)
|
|
18
|
-
|
|
19
|
-
# Set up autoloading paths
|
|
20
|
-
initializer "api_keys.autoload", before: :set_autoload_paths do |app|
|
|
21
|
-
app.config.autoload_paths << root.join("lib")
|
|
22
|
-
app.config.autoload_paths << root.join("lib/api_keys/models")
|
|
23
|
-
app.config.autoload_paths << root.join("lib/api_keys/models/concerns")
|
|
24
|
-
end
|
|
11
|
+
# Backward-compatible fallback for applications that used the old internal
|
|
12
|
+
# Engine config directly. New applications should configure
|
|
13
|
+
# ApiKeys.configuration.parent_controller; an explicit public configuration
|
|
14
|
+
# value takes precedence over this fallback.
|
|
15
|
+
config.parent_controller = nil
|
|
25
16
|
|
|
26
17
|
# Add has_api_keys method to ActiveRecord::Base
|
|
27
18
|
initializer "api_keys.active_record" do
|
|
@@ -38,12 +29,6 @@ module ApiKeys
|
|
|
38
29
|
initializer "api_keys.model_attributes" do
|
|
39
30
|
ActiveSupport.on_load(:active_record) do
|
|
40
31
|
ApiKeys::ApiKey.class_eval do
|
|
41
|
-
# Define JSON attributes for ApiKey model
|
|
42
|
-
# Ensure the ApiKey model class is loaded before reopening
|
|
43
|
-
# Use require_dependency for development/test, rely on autoloading in production
|
|
44
|
-
# Or simply let Zeitwerk handle loading if structure is correct.
|
|
45
|
-
require_dependency "api_keys/models/api_key" if defined?(Rails) && !Rails.env.production?
|
|
46
|
-
|
|
47
32
|
# == JSON Attribute Casting ==
|
|
48
33
|
# Make the gem work in any database (postgres, sqlite3, mysql...)
|
|
49
34
|
# Configure the right json-like attributes for the different databases
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiKeys
|
|
4
|
+
module Errors
|
|
5
|
+
# Base error class for all ApiKeys errors
|
|
6
|
+
class BaseError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when attempting to revoke or destroy a non-revocable key
|
|
9
|
+
class KeyNotRevocableError < BaseError
|
|
10
|
+
def initialize(message = "This API key cannot be revoked or deleted")
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when API key environment doesn't match current environment (strict isolation)
|
|
16
|
+
class EnvironmentMismatchError < BaseError
|
|
17
|
+
attr_reader :key_environment, :current_environment
|
|
18
|
+
|
|
19
|
+
def initialize(key_environment:, current_environment:)
|
|
20
|
+
@key_environment = key_environment
|
|
21
|
+
@current_environment = current_environment
|
|
22
|
+
super("API key environment (#{key_environment}) does not match current environment (#{current_environment})")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised when an invalid key type is specified
|
|
27
|
+
class InvalidKeyTypeError < BaseError
|
|
28
|
+
attr_reader :key_type, :valid_types
|
|
29
|
+
|
|
30
|
+
def initialize(key_type:, valid_types:)
|
|
31
|
+
@key_type = key_type
|
|
32
|
+
@valid_types = valid_types
|
|
33
|
+
super("Invalid key type '#{key_type}'. Valid types: #{valid_types.join(', ')}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raised when an invalid environment is specified
|
|
38
|
+
class InvalidEnvironmentError < BaseError
|
|
39
|
+
attr_reader :environment, :valid_environments
|
|
40
|
+
|
|
41
|
+
def initialize(environment:, valid_environments:)
|
|
42
|
+
@environment = environment
|
|
43
|
+
@valid_environments = valid_environments
|
|
44
|
+
super("Invalid environment '#{environment}'. Valid environments: #{valid_environments.join(', ')}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raised when key limit per type/environment is exceeded
|
|
49
|
+
class KeyLimitExceededError < BaseError
|
|
50
|
+
attr_reader :key_type, :environment, :limit
|
|
51
|
+
|
|
52
|
+
def initialize(key_type:, environment:, limit:)
|
|
53
|
+
@key_type = key_type
|
|
54
|
+
@environment = environment
|
|
55
|
+
@limit = limit
|
|
56
|
+
super("Maximum number of #{key_type} keys (#{limit}) reached for #{environment} environment")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Raised when key_types are configured but required database columns are missing
|
|
61
|
+
class MigrationRequiredError < BaseError
|
|
62
|
+
attr_reader :missing_columns
|
|
63
|
+
|
|
64
|
+
def initialize(missing_columns:)
|
|
65
|
+
@missing_columns = missing_columns
|
|
66
|
+
super(
|
|
67
|
+
"Key types are configured but required database columns are missing: #{missing_columns.join(', ')}. " \
|
|
68
|
+
"Run: rails generate api_keys:add_key_types && rails db:migrate"
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|