api_keys 0.1.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 +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +393 -0
- data/Rakefile +32 -0
- data/api_keys_dashboard.webp +0 -0
- data/api_keys_permissions.webp +0 -0
- data/api_keys_token.webp +0 -0
- data/app/controllers/api_keys/application_controller.rb +62 -0
- data/app/controllers/api_keys/keys_controller.rb +129 -0
- data/app/controllers/api_keys/security_controller.rb +16 -0
- data/app/views/api_keys/keys/_form.html.erb +106 -0
- data/app/views/api_keys/keys/_key_row.html.erb +72 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +52 -0
- data/app/views/api_keys/keys/_show_token.html.erb +88 -0
- data/app/views/api_keys/keys/edit.html.erb +5 -0
- data/app/views/api_keys/keys/index.html.erb +26 -0
- data/app/views/api_keys/keys/new.html.erb +5 -0
- data/app/views/api_keys/keys/show.html.erb +12 -0
- data/app/views/api_keys/security/best_practices.html.erb +70 -0
- data/app/views/layouts/api_keys/application.html.erb +115 -0
- data/config/routes.rb +18 -0
- data/lib/api_keys/authentication.rb +160 -0
- data/lib/api_keys/configuration.rb +125 -0
- data/lib/api_keys/controller.rb +47 -0
- data/lib/api_keys/engine.rb +76 -0
- data/lib/api_keys/jobs/callbacks_job.rb +69 -0
- data/lib/api_keys/jobs/update_stats_job.rb +58 -0
- data/lib/api_keys/logging.rb +42 -0
- data/lib/api_keys/models/api_key.rb +209 -0
- data/lib/api_keys/models/concerns/has_api_keys.rb +144 -0
- data/lib/api_keys/services/authenticator.rb +255 -0
- data/lib/api_keys/services/digestor.rb +68 -0
- data/lib/api_keys/services/token_generator.rb +32 -0
- data/lib/api_keys/tenant_resolution.rb +40 -0
- data/lib/api_keys/version.rb +5 -0
- data/lib/api_keys.rb +49 -0
- data/lib/generators/api_keys/install_generator.rb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +100 -0
- data/lib/generators/api_keys/templates/initializer.rb +160 -0
- metadata +184 -0
@@ -0,0 +1,160 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require_relative "services/authenticator"
|
5
|
+
require_relative "logging"
|
6
|
+
require_relative "jobs/update_stats_job"
|
7
|
+
require_relative "jobs/callbacks_job"
|
8
|
+
|
9
|
+
module ApiKeys
|
10
|
+
# Controller concern for handling API key authentication.
|
11
|
+
# Provides `authenticate_api_key!` method and helper methods.
|
12
|
+
module Authentication
|
13
|
+
extend ActiveSupport::Concern
|
14
|
+
include ApiKeys::Logging
|
15
|
+
|
16
|
+
included do
|
17
|
+
# Helper methods to access the authenticated key and its owner
|
18
|
+
helper_method :current_api_key, :current_api_owner, :current_api_user
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns the currently authenticated API key instance if authentication was
|
22
|
+
# successful, otherwise returns nil.
|
23
|
+
#
|
24
|
+
# @return [ApiKeys::ApiKey, nil]
|
25
|
+
def current_api_key
|
26
|
+
@current_api_key
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns the owner of the currently authenticated ApiKey, if any.
|
30
|
+
# @return [Object, nil] The polymorphic owner instance (e.g., User).
|
31
|
+
def current_api_owner
|
32
|
+
current_api_key&.owner
|
33
|
+
end
|
34
|
+
|
35
|
+
# Convenience helper: returns the owner if it's a User instance.
|
36
|
+
# @return [User, nil]
|
37
|
+
def current_api_user
|
38
|
+
owner = current_api_owner
|
39
|
+
owner if owner.is_a?(::User) # Assumes a User class exists
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
# The core authentication method.
|
45
|
+
def authenticate_api_key!(scope: nil)
|
46
|
+
log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
|
47
|
+
|
48
|
+
# Enqueue before_authentication callback asynchronously
|
49
|
+
enqueue_callback(:before_authentication, { request_uuid: request.uuid })
|
50
|
+
|
51
|
+
# Perform synchronous authentication
|
52
|
+
result = Services::Authenticator.call(request)
|
53
|
+
log_debug "[ApiKeys Auth] Authenticator result: #{result.inspect}"
|
54
|
+
|
55
|
+
# Prepare context for after_authentication callback
|
56
|
+
after_auth_context = {
|
57
|
+
success: result.success?,
|
58
|
+
error_code: result.error_code,
|
59
|
+
message: result.message,
|
60
|
+
api_key_id: result.api_key&.id # Pass ID only, not the full object
|
61
|
+
}
|
62
|
+
|
63
|
+
if result.success?
|
64
|
+
@current_api_key = result.api_key
|
65
|
+
log_debug "[ApiKeys Auth] Authentication successful. Key ID: #{@current_api_key.id}"
|
66
|
+
|
67
|
+
if scope && !check_api_key_scopes(scope)
|
68
|
+
log_debug "[ApiKeys Auth] Scope check failed. Required: #{scope}, Key scopes: #{@current_api_key.scopes}"
|
69
|
+
# Add required scope info to context before rendering/enqueueing
|
70
|
+
after_auth_context[:required_scope_check] = { required: scope, passed: false }
|
71
|
+
render_unauthorized(error_code: :missing_scope, message: "API key does not have the required scope(s): #{scope}", required_scope: scope)
|
72
|
+
else
|
73
|
+
after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
|
74
|
+
# Authentication and scope check successful, enqueue stats update
|
75
|
+
update_key_usage_stats # Enqueues UpdateStatsJob
|
76
|
+
end
|
77
|
+
else
|
78
|
+
# Authentication failed
|
79
|
+
log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}"
|
80
|
+
render_unauthorized(error_code: result.error_code, message: result.message)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Enqueue after_authentication callback asynchronously regardless of success/failure
|
84
|
+
enqueue_callback(:after_authentication, after_auth_context)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Checks if the current_api_key has the required scope(s).
|
88
|
+
# Handles single scope string or array of scopes.
|
89
|
+
#
|
90
|
+
# @param required_scopes [String, Array<String>] The required scope(s).
|
91
|
+
# @return [Boolean] True if the key has all required scopes, false otherwise.
|
92
|
+
def check_api_key_scopes(required_scopes)
|
93
|
+
return true unless current_api_key # Should not happen if authenticate_api_key! ran
|
94
|
+
return true if required_scopes.blank?
|
95
|
+
|
96
|
+
Array(required_scopes).all? do |req_scope|
|
97
|
+
current_api_key.allows_scope?(req_scope)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Renders a standard JSON error response for authentication failures.
|
102
|
+
def render_unauthorized(error_code:, message:, status: :unauthorized, required_scope: nil)
|
103
|
+
error_message = I18n.t("api_keys.errors.#{error_code}", default: message) rescue message
|
104
|
+
response_body = { error: error_code, message: error_message }
|
105
|
+
response_body[:required_scope] = required_scope if error_code == :missing_scope && required_scope
|
106
|
+
render json: response_body, status: status
|
107
|
+
end
|
108
|
+
|
109
|
+
# Enqueues the UpdateStatsJob.
|
110
|
+
def update_key_usage_stats
|
111
|
+
# Return early if async operations are globally disabled
|
112
|
+
return unless ApiKeys.configuration.enable_async_operations
|
113
|
+
return unless current_api_key
|
114
|
+
|
115
|
+
# Check ActiveJob configuration and warn if using suboptimal adapters
|
116
|
+
adapter = ActiveJob::Base.queue_adapter
|
117
|
+
if adapter.is_a?(ActiveJob::QueueAdapters::InlineAdapter)
|
118
|
+
log_warn "[ApiKeys] ActiveJob adapter is :inline. ApiKey stats updates will run synchronously within the request cycle, potentially impacting performance."
|
119
|
+
elsif adapter.is_a?(ActiveJob::QueueAdapters::AsyncAdapter)
|
120
|
+
log_warn "[ApiKeys] ActiveJob adapter is :async. ApiKey stats updates run in-process and may be lost on application restarts. Configure a persistent backend (Sidekiq, GoodJob, SolidQueue, etc.) for reliability."
|
121
|
+
end
|
122
|
+
|
123
|
+
begin
|
124
|
+
timestamp = Time.current # Capture time once for the job
|
125
|
+
log_debug "[ApiKeys Auth] Enqueuing UpdateStatsJob for ApiKey ID: #{current_api_key.id} at #{timestamp}"
|
126
|
+
ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
|
127
|
+
rescue StandardError => e
|
128
|
+
log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id}: #{e.message}"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Helper to safely enqueue callback jobs.
|
133
|
+
def enqueue_callback(callback_type, context)
|
134
|
+
# Return early if async operations are globally disabled
|
135
|
+
return unless ApiKeys.configuration.enable_async_operations
|
136
|
+
|
137
|
+
# Determine the configuration method name (e.g., :before_authentication)
|
138
|
+
config_method = callback_type # Assuming callback_type directly matches config accessor
|
139
|
+
|
140
|
+
# Get the configured proc
|
141
|
+
callback_proc = ApiKeys.configuration.public_send(config_method)
|
142
|
+
|
143
|
+
# Skip enqueueing if the callback is the default empty proc
|
144
|
+
if callback_proc == ApiKeys::Configuration::DEFAULT_CALLBACK
|
145
|
+
log_debug "[ApiKeys Auth] Skipping enqueue for default empty callback: #{callback_type}"
|
146
|
+
return
|
147
|
+
end
|
148
|
+
|
149
|
+
# Proceed with enqueueing if it's a configured callback
|
150
|
+
begin
|
151
|
+
log_debug "[ApiKeys Auth] Enqueuing CallbacksJob for type: #{callback_type} with context: #{context.inspect}"
|
152
|
+
ApiKeys::Jobs::CallbacksJob.perform_later(callback_type, context)
|
153
|
+
rescue StandardError => e
|
154
|
+
log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type}: #{e.message}"
|
155
|
+
# Don't fail the request if callback enqueueing fails
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/numeric/time"
|
4
|
+
require "active_support/security_utils"
|
5
|
+
|
6
|
+
module ApiKeys
|
7
|
+
# Defines the configuration options for the ApiKeys gem.
|
8
|
+
# These options can be set in an initializer, e.g., config/initializers/api_keys.rb
|
9
|
+
class Configuration
|
10
|
+
# Default empty callback proc
|
11
|
+
DEFAULT_CALLBACK = ->(_context){}.freeze
|
12
|
+
|
13
|
+
# == Accessors ==
|
14
|
+
|
15
|
+
# Core Authentication
|
16
|
+
attr_accessor :header, :query_param
|
17
|
+
|
18
|
+
# Token Generation
|
19
|
+
attr_accessor :token_prefix, :token_length, :token_alphabet
|
20
|
+
|
21
|
+
# Storage & Verification
|
22
|
+
attr_accessor :hash_strategy, :secure_compare_proc, :key_store_adapter, :policy_provider
|
23
|
+
|
24
|
+
# Engine Configuration
|
25
|
+
attr_accessor :parent_controller
|
26
|
+
|
27
|
+
# Optional Behaviors
|
28
|
+
attr_accessor :default_max_keys_per_owner, :require_key_name
|
29
|
+
attr_accessor :expire_after, :default_scopes, :track_requests_count
|
30
|
+
|
31
|
+
# Performance
|
32
|
+
attr_accessor :cache_ttl
|
33
|
+
|
34
|
+
# Security
|
35
|
+
attr_accessor :https_only_production, :https_strict_mode
|
36
|
+
|
37
|
+
# Tenant Resolution
|
38
|
+
attr_accessor :tenant_resolver
|
39
|
+
|
40
|
+
# Callbacks (Placeholders for future extension)
|
41
|
+
attr_accessor :before_authentication, :after_authentication
|
42
|
+
|
43
|
+
# Background Job Queues
|
44
|
+
attr_accessor :stats_job_queue, :callbacks_job_queue
|
45
|
+
|
46
|
+
# Global Async Toggle
|
47
|
+
attr_accessor :enable_async_operations
|
48
|
+
|
49
|
+
# Engine UI Configuration
|
50
|
+
attr_accessor :return_url, :return_text
|
51
|
+
|
52
|
+
# Debugging
|
53
|
+
attr_accessor :debug_logging
|
54
|
+
|
55
|
+
# == Initialization ==
|
56
|
+
|
57
|
+
def initialize
|
58
|
+
set_defaults
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def set_defaults
|
64
|
+
# Core Authentication
|
65
|
+
@header = "Authorization" # Expects "Bearer <token>"
|
66
|
+
@query_param = nil # No query param lookup by default
|
67
|
+
|
68
|
+
# Token Generation
|
69
|
+
@token_prefix = -> { "ak_" }
|
70
|
+
@token_length = 24 # Bytes of entropy
|
71
|
+
@token_alphabet = :base58 # Avoid ambiguous chars (0, O, I, l)
|
72
|
+
|
73
|
+
# Storage & Verification
|
74
|
+
@hash_strategy = :sha256 # sha256 or :bcrypt
|
75
|
+
@secure_compare_proc = ->(a, b) { ActiveSupport::SecurityUtils.secure_compare(a, b) }
|
76
|
+
@key_store_adapter = :active_record # Default storage backend
|
77
|
+
# TODO: Define and implement ApiKeys::BasePolicy in later versions
|
78
|
+
# This will define the authorization policy class used to check if a key is valid beyond basic checks.
|
79
|
+
# Allows injecting custom logic (IP allow-listing, time-of-day checks, etc.).
|
80
|
+
# Must be a class name (String or Class) responding to `.new(api_key, request).valid?`
|
81
|
+
# Default: "ApiKeys::BasePolicy" (a basic implementation should be provided)
|
82
|
+
@policy_provider = "ApiKeys::BasePolicy" # Default authorization policy class name
|
83
|
+
|
84
|
+
# Engine Configuration
|
85
|
+
@parent_controller = '::ApplicationController'
|
86
|
+
|
87
|
+
# Optional Behaviors
|
88
|
+
@default_max_keys_per_owner = nil # No global key limit per owner
|
89
|
+
@require_key_name = false # Don't require names for keys globally
|
90
|
+
@expire_after = nil # Keys do not expire by default (e.g., 90.days)
|
91
|
+
@default_scopes = [] # No default scopes assigned globally
|
92
|
+
|
93
|
+
# Performance
|
94
|
+
@cache_ttl = 5.seconds # Good balance: fast revocation, mostly-cached – still allows most repeated requests to benefit from cache
|
95
|
+
|
96
|
+
# Security
|
97
|
+
@https_only_production = true # Warn if used over HTTP in production
|
98
|
+
@https_strict_mode = false # Don't raise error, just warn
|
99
|
+
|
100
|
+
# Background Job Queues
|
101
|
+
@stats_job_queue = :default
|
102
|
+
@callbacks_job_queue = :default
|
103
|
+
|
104
|
+
# Global Async Toggle
|
105
|
+
@enable_async_operations = true # Default to true to enable jobs
|
106
|
+
|
107
|
+
# Usage Statistics
|
108
|
+
@track_requests_count = false # Don't increment `requests_count` by default
|
109
|
+
|
110
|
+
# Callbacks
|
111
|
+
@before_authentication = DEFAULT_CALLBACK
|
112
|
+
@after_authentication = DEFAULT_CALLBACK
|
113
|
+
|
114
|
+
# Engine UI Configuration
|
115
|
+
@return_url = "/" # Default fallback path
|
116
|
+
@return_text = "‹ Home" # Default link text
|
117
|
+
|
118
|
+
# Debugging
|
119
|
+
@debug_logging = false # Disable debug logging by default (warn and error get logged regardless of this)
|
120
|
+
|
121
|
+
# Tenant Resolution
|
122
|
+
@tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require_relative "authentication" # Include authentication logic
|
5
|
+
require_relative "tenant_resolution" # Include tenant resolution logic
|
6
|
+
|
7
|
+
module ApiKeys
|
8
|
+
# Unified controller concern that bundles common ApiKeys functionality
|
9
|
+
# for easy inclusion in controllers.
|
10
|
+
#
|
11
|
+
# Includes:
|
12
|
+
# - ApiKeys::Authentication (provides authenticate_api_key!, current_api_key, etc.)
|
13
|
+
# - ApiKeys::TenantResolution (provides current_api_tenant)
|
14
|
+
#
|
15
|
+
# == Usage
|
16
|
+
#
|
17
|
+
# class Api::BaseController < ActionController::API
|
18
|
+
# include ApiKeys::Controller
|
19
|
+
#
|
20
|
+
# before_action :authenticate_api_key!
|
21
|
+
#
|
22
|
+
# def show
|
23
|
+
# # Access helpers provided by the included concerns
|
24
|
+
# key = current_api_key
|
25
|
+
# owner = current_api_owner
|
26
|
+
# tenant = current_api_tenant
|
27
|
+
# # ...
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
module Controller
|
32
|
+
extend ActiveSupport::Concern
|
33
|
+
|
34
|
+
included do
|
35
|
+
# Bring in the functionality from the specific concerns
|
36
|
+
include ApiKeys::Authentication
|
37
|
+
include ApiKeys::TenantResolution
|
38
|
+
|
39
|
+
# You could add further convenience methods here if needed,
|
40
|
+
# potentially combining logic from both included concerns.
|
41
|
+
end
|
42
|
+
|
43
|
+
# Add any class methods specific to this unified concern if necessary
|
44
|
+
# module ClassMethods
|
45
|
+
# end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/engine"
|
4
|
+
require_relative "models/concerns/has_api_keys" # Ensure concern is loaded
|
5
|
+
|
6
|
+
module ApiKeys
|
7
|
+
# Rails engine for ApiKeys
|
8
|
+
class Engine < ::Rails::Engine
|
9
|
+
isolate_namespace ApiKeys
|
10
|
+
|
11
|
+
# Allows configuring the parent controller for the engine's controllers
|
12
|
+
# Defaults to ::ApplicationController, assuming a standard Rails app structure.
|
13
|
+
config.parent_controller = '::ApplicationController'
|
14
|
+
|
15
|
+
# Ensure our models load first
|
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
|
25
|
+
|
26
|
+
# Add has_api_keys method to ActiveRecord::Base
|
27
|
+
initializer "api_keys.active_record" do
|
28
|
+
ActiveSupport.on_load(:active_record) do
|
29
|
+
# Extend all AR models with the ClassMethods module from HasApiKeys
|
30
|
+
# This makes the `has_api_keys` method available directly on models like User.
|
31
|
+
extend ApiKeys::Models::Concerns::HasApiKeys::ClassMethods
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
# Load JSON attribute types after ActiveRecord initialization
|
37
|
+
# and database connection is established.
|
38
|
+
initializer "api_keys.model_attributes" do
|
39
|
+
ActiveSupport.on_load(:active_record) do
|
40
|
+
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
|
+
# == JSON Attribute Casting ==
|
48
|
+
# Make the gem work in any database (postgres, sqlite3, mysql...)
|
49
|
+
# Configure the right json-like attributes for the different databases
|
50
|
+
# according to the migration (jsonb = postgres; text = elsewhere)
|
51
|
+
# So that the JSON attributes work in any database
|
52
|
+
# and the gem works everywhere, transparent to end users
|
53
|
+
json_col_type = ApiKeys::ApiKey.connection.adapter_name.downcase.include?('postg') ? :jsonb : :json
|
54
|
+
ApiKeys::ApiKey.attribute :scopes, json_col_type, default: []
|
55
|
+
ApiKeys::ApiKey.attribute :metadata, json_col_type, default: {}
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
# Add other initializers here if needed (e.g., for configuration loading,
|
63
|
+
# middleware injection, asset precompilation, etc.)
|
64
|
+
|
65
|
+
# Example: Load configuration defaults
|
66
|
+
# initializer "api_keys.configuration" do
|
67
|
+
# require_relative "../config"
|
68
|
+
# # Potentially load default config values here
|
69
|
+
# end
|
70
|
+
|
71
|
+
# Example: Add middleware if needed
|
72
|
+
# initializer "api_keys.middleware" do |app|
|
73
|
+
# # app.middleware.use ApiKeys::Middleware::SomeMiddleware
|
74
|
+
# end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_job"
|
4
|
+
require_relative "../logging"
|
5
|
+
require_relative "../configuration" # Access configuration for callbacks
|
6
|
+
|
7
|
+
module ApiKeys
|
8
|
+
module Jobs
|
9
|
+
# Background job to execute configured lifecycle callbacks asynchronously.
|
10
|
+
class CallbacksJob < ActiveJob::Base
|
11
|
+
include ApiKeys::Logging
|
12
|
+
|
13
|
+
# Use the queue name specified in the configuration (evaluated at load time)
|
14
|
+
queue_as ApiKeys.configuration.callbacks_job_queue
|
15
|
+
|
16
|
+
# Executes the appropriate callback based on the type.
|
17
|
+
#
|
18
|
+
# @param callback_type [Symbol] :before_authentication or :after_authentication
|
19
|
+
# @param context [Hash] Serializable context data for the callback.
|
20
|
+
def perform(callback_type, context = {})
|
21
|
+
config = ApiKeys.configuration
|
22
|
+
|
23
|
+
case callback_type
|
24
|
+
when :before_authentication
|
25
|
+
execute_callback(config.before_authentication, context)
|
26
|
+
when :after_authentication
|
27
|
+
execute_callback(config.after_authentication, context)
|
28
|
+
else
|
29
|
+
log_warn "[ApiKeys::Jobs::CallbacksJob] Unknown callback type: #{callback_type}"
|
30
|
+
end
|
31
|
+
rescue StandardError => e
|
32
|
+
log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} with context #{context.inspect}: #{e.class}: #{e.message}
|
33
|
+
#{e.backtrace.join("
|
34
|
+
")}"
|
35
|
+
# Avoid retrying callback errors by default, as the original request succeeded.
|
36
|
+
# Depending on callback importance, users might configure retries separately.
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# Safely executes a user-provided callback lambda.
|
42
|
+
#
|
43
|
+
# @param callback_proc [Proc, Lambda] The configured callback.
|
44
|
+
# @param context [Hash] The context data to pass.
|
45
|
+
def execute_callback(callback_proc, context)
|
46
|
+
unless callback_proc.is_a?(Proc)
|
47
|
+
log_debug "[ApiKeys::Jobs::CallbacksJob] Callback is not a Proc, skipping execution."
|
48
|
+
return
|
49
|
+
end
|
50
|
+
|
51
|
+
arity = callback_proc.arity
|
52
|
+
log_debug "[ApiKeys::Jobs::CallbacksJob] Executing callback with arity #{arity}"
|
53
|
+
|
54
|
+
begin
|
55
|
+
if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
|
56
|
+
callback_proc.call(context)
|
57
|
+
elsif arity == 0 # Handle procs accepting no args
|
58
|
+
callback_proc.call
|
59
|
+
else
|
60
|
+
log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
|
61
|
+
end
|
62
|
+
rescue StandardError => e
|
63
|
+
# Log the specific error from the user's callback code
|
64
|
+
raise # Re-raise to be caught by the main perform rescue block for logging
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_job"
|
4
|
+
require_relative "../models/api_key"
|
5
|
+
require_relative "../logging"
|
6
|
+
|
7
|
+
module ApiKeys
|
8
|
+
module Jobs
|
9
|
+
# Background job to update API key usage statistics (last_used_at, requests_count).
|
10
|
+
# Enqueued by the Authentication concern after a successful request.
|
11
|
+
class UpdateStatsJob < ActiveJob::Base
|
12
|
+
include ApiKeys::Logging # Include logging helpers
|
13
|
+
|
14
|
+
# Use the queue name specified in the configuration (evaluated at load time)
|
15
|
+
queue_as ApiKeys.configuration.stats_job_queue
|
16
|
+
|
17
|
+
# Perform the database updates for the given ApiKey.
|
18
|
+
#
|
19
|
+
# @param api_key_id [Integer, String] The ID of the ApiKey to update.
|
20
|
+
# @param timestamp [Time] The timestamp of the request (when it was authenticated).
|
21
|
+
def perform(api_key_id, timestamp)
|
22
|
+
api_key = ApiKey.find_by(id: api_key_id)
|
23
|
+
|
24
|
+
unless api_key
|
25
|
+
log_warn "[ApiKeys::Jobs::UpdateStatsJob] ApiKey not found with ID: #{api_key_id}. Skipping stats update."
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
29
|
+
log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id} at #{timestamp}"
|
30
|
+
|
31
|
+
# Use provided timestamp for consistency
|
32
|
+
# Use update_column to skip validations/callbacks for performance
|
33
|
+
api_key.update_column(:last_used_at, timestamp)
|
34
|
+
|
35
|
+
# Conditionally increment requests_count if configured
|
36
|
+
if ApiKeys.configuration.track_requests_count
|
37
|
+
# Use increment_counter for atomic updates
|
38
|
+
ApiKey.increment_counter(:requests_count, api_key.id)
|
39
|
+
log_debug "[ApiKeys::Jobs::UpdateStatsJob] Incremented requests_count for ApiKey ID: #{api_key_id}"
|
40
|
+
end
|
41
|
+
|
42
|
+
log_debug "[ApiKeys::Jobs::UpdateStatsJob] Finished updating stats for ApiKey ID: #{api_key_id}"
|
43
|
+
|
44
|
+
rescue ActiveRecord::ActiveRecordError => e
|
45
|
+
# Log error but don't automatically retry unless configured to do so.
|
46
|
+
# Frequent stats updates might tolerate occasional failures better than endless retries.
|
47
|
+
log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id}. Error: #{e.message}"
|
48
|
+
# Depending on ActiveJob adapter, specific retry logic might be needed here
|
49
|
+
# or configured globally. For now, just log.
|
50
|
+
rescue StandardError => e
|
51
|
+
log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id}. Error: #{e.class}: #{e.message}
|
52
|
+
#{e.backtrace.join("
|
53
|
+
")}"
|
54
|
+
# Consider re-raising or using a dead-letter queue strategy depending on job system
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ApiKeys
|
4
|
+
# Shared logging utilities for the ApiKeys gem.
|
5
|
+
module Logging
|
6
|
+
private
|
7
|
+
|
8
|
+
# Helper for conditional debug logging based on configuration.
|
9
|
+
#
|
10
|
+
# @param message [String] The message to log.
|
11
|
+
def log_debug(message)
|
12
|
+
# Only log if debug_logging is enabled and a logger is available
|
13
|
+
if ApiKeys.configuration.debug_logging && logger
|
14
|
+
logger.debug(message)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Helper for conditional warning logging.
|
19
|
+
# Warnings are logged regardless of debug flag, if logger available.
|
20
|
+
#
|
21
|
+
# @param message [String] The message to log.
|
22
|
+
def log_warn(message)
|
23
|
+
logger.warn(message) if logger
|
24
|
+
end
|
25
|
+
|
26
|
+
# Helper for logging errors.
|
27
|
+
# Errors are logged regardless of debug flag, if logger available.
|
28
|
+
#
|
29
|
+
# @param message [String] The message to log.
|
30
|
+
def log_error(message)
|
31
|
+
logger.error(message) if logger
|
32
|
+
end
|
33
|
+
|
34
|
+
# Provides access to the logger instance (Rails.logger if defined).
|
35
|
+
#
|
36
|
+
# @return [Logger, nil] The logger instance or nil.
|
37
|
+
def logger
|
38
|
+
# Memoize the logger instance for performance
|
39
|
+
@_api_keys_logger ||= defined?(Rails) ? Rails.logger : nil
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|