otto 2.0.0.pre2 → 2.0.0.pre7
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/.github/workflows/ci.yml +1 -3
- data/.github/workflows/claude-code-review.yml +29 -13
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +116 -45
- data/Gemfile +5 -2
- data/Gemfile.lock +70 -24
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/authentication_strategies/config.ru +0 -1
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +90 -45
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +25 -18
- data/lib/otto/core/router.rb +62 -9
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +10 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +65 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +85 -2
- data/lib/otto/helpers/response.rb +5 -5
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +6 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +5 -0
- data/lib/otto/privacy/config.rb +201 -0
- data/lib/otto/privacy/geo_resolver.rb +285 -0
- data/lib/otto/privacy/ip_privacy.rb +177 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
- data/lib/otto/privacy.rb +31 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -1
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +18 -16
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +25 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
- data/lib/otto/security/authentication/auth_strategy.rb +13 -6
- data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +53 -9
- data/lib/otto/security/configurator.rb +17 -15
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +12 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +344 -89
- data/otto.gemspec +9 -2
- metadata +72 -8
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# lib/otto/logging_helpers.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
# LoggingHelpers provides utility methods for consistent structured logging
|
|
7
|
+
# across the Otto framework. Centralizes common request context extraction
|
|
8
|
+
# to eliminate duplication while keeping logging calls simple and explicit.
|
|
9
|
+
module LoggingHelpers
|
|
10
|
+
# Structured logging helpers for Otto framework.
|
|
11
|
+
#
|
|
12
|
+
# BASE CONTEXT PATTERN (recommended for downstream projects):
|
|
13
|
+
#
|
|
14
|
+
# Create base context once per error/event, then merge event-specific fields.
|
|
15
|
+
#
|
|
16
|
+
# THREAD SAFETY: This pattern is thread-safe for concurrent requests. Each
|
|
17
|
+
# request has its own `env` hash, so `request_context(env)` creates isolated
|
|
18
|
+
# context hashes per request. The pattern extracts immutable values (strings,
|
|
19
|
+
# symbols) from `env`, and `.merge()` creates new hashes rather than mutating
|
|
20
|
+
# shared state. Safe for use in multi-threaded Rack servers (Puma, Falcon).
|
|
21
|
+
#
|
|
22
|
+
# base_context = Otto::LoggingHelpers.request_context(env)
|
|
23
|
+
#
|
|
24
|
+
# Otto.structured_log(:error, "Handler failed",
|
|
25
|
+
# base_context.merge(
|
|
26
|
+
# error: error.message,
|
|
27
|
+
# error_class: error.class.name,
|
|
28
|
+
# error_id: error_id,
|
|
29
|
+
# duration: duration
|
|
30
|
+
# )
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# Otto::LoggingHelpers.log_backtrace(error,
|
|
34
|
+
# base_context.merge(error_id: error_id, handler: 'Controller#action')
|
|
35
|
+
# )
|
|
36
|
+
#
|
|
37
|
+
# DOWNSTREAM EXTENSIBILITY:
|
|
38
|
+
#
|
|
39
|
+
# Large projects can inject custom shared fields:
|
|
40
|
+
#
|
|
41
|
+
# custom_base = Otto::LoggingHelpers.request_context(env).merge(
|
|
42
|
+
# transaction_id: Thread.current[:transaction_id],
|
|
43
|
+
# user_id: env['otto.user']&.id,
|
|
44
|
+
# tenant_id: env['tenant_id']
|
|
45
|
+
# )
|
|
46
|
+
#
|
|
47
|
+
# Otto.structured_log(:error, "Business operation failed",
|
|
48
|
+
# custom_base.merge(
|
|
49
|
+
# error: error.message,
|
|
50
|
+
# error_class: error.class.name,
|
|
51
|
+
# account_id: account.id,
|
|
52
|
+
# operation: :withdrawal
|
|
53
|
+
# )
|
|
54
|
+
# )
|
|
55
|
+
#
|
|
56
|
+
|
|
57
|
+
# Extract common request context for structured logging
|
|
58
|
+
#
|
|
59
|
+
# Returns a hash containing privacy-aware request metadata suitable
|
|
60
|
+
# for merging with event-specific data in Otto.structured_log calls.
|
|
61
|
+
#
|
|
62
|
+
# @param env [Hash] Rack environment hash
|
|
63
|
+
# @return [Hash] Request context with method, path, ip, country, user_agent
|
|
64
|
+
#
|
|
65
|
+
# @example Basic usage
|
|
66
|
+
# Otto.structured_log(:info, "Route matched",
|
|
67
|
+
# Otto::LoggingHelpers.request_context(env).merge(
|
|
68
|
+
# type: 'literal',
|
|
69
|
+
# handler: 'App#index'
|
|
70
|
+
# )
|
|
71
|
+
# )
|
|
72
|
+
#
|
|
73
|
+
# @note IP addresses are already masked by IPPrivacyMiddleware (public IPs only)
|
|
74
|
+
# @note User agents are truncated to 100 chars to prevent log bloat
|
|
75
|
+
def self.request_context(env)
|
|
76
|
+
{
|
|
77
|
+
method: env['REQUEST_METHOD'],
|
|
78
|
+
path: env['PATH_INFO'],
|
|
79
|
+
ip: env['REMOTE_ADDR'], # Already masked by IPPrivacyMiddleware for public IPs
|
|
80
|
+
country: env['otto.geo_country'],
|
|
81
|
+
user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
|
|
82
|
+
}.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Log a timed operation with consistent timing and error handling
|
|
86
|
+
#
|
|
87
|
+
# @param level [Symbol] The log level (:debug, :info, :warn, :error)
|
|
88
|
+
# @param message [String] The log message
|
|
89
|
+
# @param env [Hash] Rack environment for request context
|
|
90
|
+
# @param metadata [Hash] Additional metadata to include in the log
|
|
91
|
+
# @yield The block to execute and time
|
|
92
|
+
# @return The result of the block
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
|
|
96
|
+
# template_type: 'handlebars', cached: false
|
|
97
|
+
# ) do
|
|
98
|
+
# compile_template(template)
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
def self.log_timed_operation(level, message, env, **metadata)
|
|
102
|
+
start_time = Otto::Utils.now_in_μs
|
|
103
|
+
result = yield
|
|
104
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
105
|
+
|
|
106
|
+
Otto.structured_log(level, message,
|
|
107
|
+
request_context(env).merge(metadata).merge(duration: duration))
|
|
108
|
+
|
|
109
|
+
result
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
112
|
+
Otto.structured_log(:error, "#{message} failed",
|
|
113
|
+
request_context(env).merge(metadata).merge(
|
|
114
|
+
duration: duration,
|
|
115
|
+
error: e.message,
|
|
116
|
+
error_class: e.class.name
|
|
117
|
+
))
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Detect project root directory for path sanitization
|
|
122
|
+
# @return [String] Absolute path to project root
|
|
123
|
+
def self.detect_project_root
|
|
124
|
+
@project_root ||= begin
|
|
125
|
+
if defined?(Bundler)
|
|
126
|
+
Bundler.root.to_s
|
|
127
|
+
else
|
|
128
|
+
Dir.pwd
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Sanitize a single backtrace line to remove sensitive path information
|
|
134
|
+
#
|
|
135
|
+
# Transforms absolute paths into relative or categorized paths:
|
|
136
|
+
# - Project files: relative path from project root
|
|
137
|
+
# - Gem files: [GEM] gem-name-version/relative/path
|
|
138
|
+
# - Ruby stdlib: [RUBY] filename
|
|
139
|
+
# - Unknown: [EXTERNAL] filename
|
|
140
|
+
#
|
|
141
|
+
# @param line [String] Raw backtrace line
|
|
142
|
+
# @param project_root [String] Project root path (auto-detected if nil)
|
|
143
|
+
# @return [String] Sanitized backtrace line
|
|
144
|
+
#
|
|
145
|
+
# @example
|
|
146
|
+
# sanitize_backtrace_line("/Users/admin/app/lib/user.rb:42:in `save'")
|
|
147
|
+
# # => "lib/user.rb:42:in `save'"
|
|
148
|
+
#
|
|
149
|
+
# sanitize_backtrace_line("/usr/local/gems/rack-3.1.8/lib/rack.rb:10")
|
|
150
|
+
# # => "[GEM] rack-3.1.8/lib/rack.rb:10"
|
|
151
|
+
#
|
|
152
|
+
def self.sanitize_backtrace_line(line, project_root = nil)
|
|
153
|
+
return line if line.nil? || line.empty?
|
|
154
|
+
|
|
155
|
+
project_root ||= detect_project_root
|
|
156
|
+
expanded_root = File.expand_path(project_root)
|
|
157
|
+
|
|
158
|
+
# Extract file path from backtrace line (format: "path:line:in `method'" or "path:line")
|
|
159
|
+
if line =~ /^(.+?):\d+(?::in `.+')?$/
|
|
160
|
+
file_path = ::Regexp.last_match(1)
|
|
161
|
+
suffix = line[file_path.length..]
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
expanded_path = File.expand_path(file_path)
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
# Handle malformed paths (e.g., containing null bytes)
|
|
167
|
+
# File.basename also raises ArgumentError for null bytes, so use simple string manipulation
|
|
168
|
+
basename = file_path.split('/').last || file_path
|
|
169
|
+
return "[EXTERNAL] #{basename}#{suffix}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Try project-relative path first
|
|
173
|
+
if expanded_path.start_with?(expanded_root + File::SEPARATOR)
|
|
174
|
+
relative_path = expanded_path.delete_prefix(expanded_root + File::SEPARATOR)
|
|
175
|
+
return relative_path + suffix
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check for bundler gems specifically (e.g., /bundler/gems/otto-abc123/lib/...)
|
|
179
|
+
# Must check BEFORE regular gems pattern to handle /gems/3.4.0/bundler/gems/...
|
|
180
|
+
if expanded_path =~ %r{/bundler/gems/([^/]+)/(.+)$}
|
|
181
|
+
gem_name = ::Regexp.last_match(1)
|
|
182
|
+
gem_relative = ::Regexp.last_match(2)
|
|
183
|
+
# Strip git hash suffix for cleaner output (otto-abc123def456 → otto)
|
|
184
|
+
gem_name = gem_name.sub(/-[a-f0-9]{7,}$/, '') if gem_name =~ /-[a-f0-9]{7,}$/i
|
|
185
|
+
return "[GEM] #{gem_name}/#{gem_relative}#{suffix}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Check for regular gem path (e.g., /path/to/gems/rack-3.1.8/lib/rack.rb)
|
|
189
|
+
# Handle version directories: /gems/3.4.0/gems/rack-3.1.8/... by looking for last /gems/
|
|
190
|
+
if expanded_path =~ %r{/gems/([^/]+)/(.+)$}
|
|
191
|
+
gem_name = ::Regexp.last_match(1)
|
|
192
|
+
gem_relative = ::Regexp.last_match(2)
|
|
193
|
+
|
|
194
|
+
# Skip version-only directory names (e.g., 3.4.0)
|
|
195
|
+
# Look deeper if gem_name is just a version number
|
|
196
|
+
if gem_name =~ /^[\d.]+$/ && gem_relative =~ %r{^(?:bundler/)?gems/([^/]+)/(.+)$}
|
|
197
|
+
# Found nested gem path, use that instead
|
|
198
|
+
gem_name = ::Regexp.last_match(1)
|
|
199
|
+
gem_relative = ::Regexp.last_match(2)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Strip version suffix for cleaner output (rack-3.1.8 → rack)
|
|
203
|
+
base_gem_name = gem_name.split('-')[0..-2].join('-')
|
|
204
|
+
base_gem_name = gem_name if base_gem_name.empty?
|
|
205
|
+
|
|
206
|
+
return "[GEM] #{base_gem_name}/#{gem_relative}#{suffix}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Check for Ruby stdlib (e.g., /path/to/ruby/3.4.0/logger.rb)
|
|
210
|
+
if expanded_path =~ %r{/ruby/[\d.]+/(.+)$}
|
|
211
|
+
stdlib_file = ::Regexp.last_match(1)
|
|
212
|
+
return "[RUBY] #{stdlib_file}#{suffix}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Unknown/external path - show filename only
|
|
216
|
+
filename = File.basename(file_path)
|
|
217
|
+
return "[EXTERNAL] #{filename}#{suffix}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Couldn't parse - return as-is (better than failing)
|
|
221
|
+
line
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Sanitize an array of backtrace lines
|
|
225
|
+
#
|
|
226
|
+
# @param backtrace [Array<String>] Raw backtrace lines
|
|
227
|
+
# @param project_root [String] Project root path (auto-detected if nil)
|
|
228
|
+
# @return [Array<String>] Sanitized backtrace lines
|
|
229
|
+
def self.sanitize_backtrace(backtrace, project_root: nil)
|
|
230
|
+
return [] if backtrace.nil? || backtrace.empty?
|
|
231
|
+
|
|
232
|
+
project_root ||= detect_project_root
|
|
233
|
+
backtrace.map { |line| sanitize_backtrace_line(line, project_root) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Log exception backtrace with correlation fields for debugging.
|
|
237
|
+
# Always logs for unhandled errors at ERROR level with sanitized paths.
|
|
238
|
+
# Limits to first 20 lines for critical errors.
|
|
239
|
+
#
|
|
240
|
+
# SECURITY: Paths are sanitized to prevent exposing sensitive system information:
|
|
241
|
+
# - Project files: Show relative paths only
|
|
242
|
+
# - Gem files: Show gem name and relative path within gem
|
|
243
|
+
# - Ruby stdlib: Show filename only
|
|
244
|
+
# - External files: Show filename only
|
|
245
|
+
#
|
|
246
|
+
# Expects caller to provide correlation context (error_id, handler, etc).
|
|
247
|
+
# Does NOT duplicate error/error_class fields - those belong in main error log.
|
|
248
|
+
#
|
|
249
|
+
# @param error [Exception] The exception to log backtrace for
|
|
250
|
+
# @param context [Hash] Correlation fields (error_id, method, path, ip, handler, etc)
|
|
251
|
+
#
|
|
252
|
+
# @example Basic usage
|
|
253
|
+
# Otto::LoggingHelpers.log_backtrace(error,
|
|
254
|
+
# base_context.merge(error_id: error_id, handler: 'UserController#create')
|
|
255
|
+
# )
|
|
256
|
+
#
|
|
257
|
+
# @example Downstream extensibility
|
|
258
|
+
# custom_context = Otto::LoggingHelpers.request_context(env).merge(
|
|
259
|
+
# error_id: error_id,
|
|
260
|
+
# transaction_id: Thread.current[:transaction_id],
|
|
261
|
+
# tenant_id: env['tenant_id']
|
|
262
|
+
# )
|
|
263
|
+
# Otto::LoggingHelpers.log_backtrace(error, custom_context)
|
|
264
|
+
#
|
|
265
|
+
def self.log_backtrace(error, context = {})
|
|
266
|
+
raw_backtrace = error.backtrace&.first(20) || []
|
|
267
|
+
sanitized = sanitize_backtrace(raw_backtrace)
|
|
268
|
+
|
|
269
|
+
Otto.structured_log(:error, 'Exception backtrace',
|
|
270
|
+
context.merge(backtrace: sanitized))
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/lib/otto/mcp/auth/token.rb
CHANGED
data/lib/otto/mcp/protocol.rb
CHANGED
data/lib/otto/mcp/registry.rb
CHANGED
data/lib/otto/mcp/server.rb
CHANGED
data/lib/otto/mcp.rb
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# lib/otto/privacy/config.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'digest'
|
|
8
|
+
|
|
9
|
+
require 'concurrent'
|
|
10
|
+
|
|
11
|
+
require_relative '../core/freezable'
|
|
12
|
+
|
|
13
|
+
class Otto
|
|
14
|
+
module Privacy
|
|
15
|
+
# Configuration for IP privacy features
|
|
16
|
+
#
|
|
17
|
+
# Privacy is ENABLED by default for public IPs. Private/localhost IPs are not masked.
|
|
18
|
+
#
|
|
19
|
+
# @example Default configuration (privacy enabled)
|
|
20
|
+
# config = Otto::Privacy::Config.new
|
|
21
|
+
# config.enabled? # => true
|
|
22
|
+
#
|
|
23
|
+
# @example Configure masking level
|
|
24
|
+
# config = Otto::Privacy::Config.new
|
|
25
|
+
# config.octet_precision = 2 # Mask 2 octets instead of 1
|
|
26
|
+
#
|
|
27
|
+
class Config
|
|
28
|
+
include Otto::Core::Freezable
|
|
29
|
+
|
|
30
|
+
attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
|
|
31
|
+
attr_reader :disabled
|
|
32
|
+
|
|
33
|
+
# Class-level rotation key storage (mutable, not frozen with instances)
|
|
34
|
+
# This is stored at the class level so it persists across frozen config instances
|
|
35
|
+
@rotation_keys_store = nil
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Get the class-level rotation keys store
|
|
39
|
+
# @return [Concurrent::Map] Thread-safe map for rotation keys
|
|
40
|
+
def rotation_keys_store
|
|
41
|
+
@rotation_keys_store = Concurrent::Map.new unless defined?(@rotation_keys_store) && @rotation_keys_store
|
|
42
|
+
@rotation_keys_store
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Initialize privacy configuration
|
|
47
|
+
#
|
|
48
|
+
# @param options [Hash] Configuration options
|
|
49
|
+
# @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
|
|
50
|
+
# @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
|
|
51
|
+
# @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
|
|
52
|
+
# @option options [Boolean] :disabled Disable privacy entirely (default: false)
|
|
53
|
+
# @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
|
|
54
|
+
# @option options [Redis] :redis Optional Redis connection for multi-server environments
|
|
55
|
+
def initialize(options = {})
|
|
56
|
+
@octet_precision = options.fetch(:octet_precision, 1)
|
|
57
|
+
@hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
|
|
58
|
+
@geo_enabled = options.fetch(:geo_enabled, true)
|
|
59
|
+
@disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
|
|
60
|
+
@mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
|
|
61
|
+
@redis = options[:redis] # Optional Redis connection for multi-server environments
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if privacy is enabled
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean] true if privacy is enabled (default)
|
|
67
|
+
def enabled?
|
|
68
|
+
!@disabled
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if privacy is disabled
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean] true if privacy was explicitly disabled
|
|
74
|
+
def disabled?
|
|
75
|
+
@disabled
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Disable privacy (allows access to original IPs)
|
|
79
|
+
#
|
|
80
|
+
# IMPORTANT: This should only be used when you have a specific
|
|
81
|
+
# requirement to access original IP addresses. By default, Otto
|
|
82
|
+
# provides privacy-safe masked IPs.
|
|
83
|
+
#
|
|
84
|
+
# @return [self]
|
|
85
|
+
def disable!
|
|
86
|
+
@disabled = true
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Enable privacy (default state)
|
|
91
|
+
#
|
|
92
|
+
# @return [self]
|
|
93
|
+
def enable!
|
|
94
|
+
@disabled = false
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get the current rotation key for IP hashing
|
|
99
|
+
#
|
|
100
|
+
# Keys rotate at fixed intervals based on hash_rotation_period (default: 24 hours).
|
|
101
|
+
# Each rotation period gets a unique key, ensuring IP addresses hash differently
|
|
102
|
+
# across periods while remaining consistent within.
|
|
103
|
+
#
|
|
104
|
+
# Multi-server support:
|
|
105
|
+
# - With Redis: Uses SET NX GET EX for atomic key generation across all servers
|
|
106
|
+
# - Without Redis: Falls back to in-memory Concurrent::Hash (single-server only)
|
|
107
|
+
#
|
|
108
|
+
# Redis keys:
|
|
109
|
+
# - rotation_key:{timestamp} - Stores the rotation key with TTL
|
|
110
|
+
#
|
|
111
|
+
# @return [String] Current rotation key for hashing
|
|
112
|
+
def rotation_key
|
|
113
|
+
if @redis
|
|
114
|
+
rotation_key_redis
|
|
115
|
+
else
|
|
116
|
+
rotation_key_memory
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validate configuration settings
|
|
121
|
+
#
|
|
122
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
123
|
+
def validate!
|
|
124
|
+
raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
|
|
125
|
+
2].include?(@octet_precision)
|
|
126
|
+
|
|
127
|
+
return unless @hash_rotation_period < 60
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, 'hash_rotation_period must be at least 60 seconds'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Redis-based rotation key (atomic across multiple servers)
|
|
135
|
+
#
|
|
136
|
+
# Uses SET NX GET EX to atomically:
|
|
137
|
+
# 1. Check if key exists
|
|
138
|
+
# 2. Set new key only if missing
|
|
139
|
+
# 3. Return existing or newly set key
|
|
140
|
+
# 4. Auto-expire with TTL
|
|
141
|
+
#
|
|
142
|
+
# @return [String] Current rotation key
|
|
143
|
+
# @api private
|
|
144
|
+
def rotation_key_redis
|
|
145
|
+
now_seconds = Time.now.utc.to_i
|
|
146
|
+
|
|
147
|
+
# Quantize to rotation period boundary
|
|
148
|
+
rotation_timestamp = (now_seconds / @hash_rotation_period) * @hash_rotation_period
|
|
149
|
+
|
|
150
|
+
redis_key = "rotation_key:#{rotation_timestamp}"
|
|
151
|
+
ttl = (@hash_rotation_period * 1.2).to_i # Auto-cleanup with 20% buffer
|
|
152
|
+
|
|
153
|
+
key = SecureRandom.hex(32)
|
|
154
|
+
|
|
155
|
+
# SET NX GET returns old value if key exists, nil if we set it
|
|
156
|
+
# @see https://valkey.io/commands/set/
|
|
157
|
+
existing_key = @redis.set(redis_key, key, nx: true, get: true, ex: ttl)
|
|
158
|
+
|
|
159
|
+
existing_key || key
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# In-memory rotation key (single-server fallback)
|
|
163
|
+
#
|
|
164
|
+
# Uses class-level Concurrent::Hash for thread-safety within a single process.
|
|
165
|
+
# NOT atomic across multiple servers.
|
|
166
|
+
#
|
|
167
|
+
# The rotation keys are stored at the class level so they remain mutable
|
|
168
|
+
# even when config instances are frozen.
|
|
169
|
+
#
|
|
170
|
+
# @return [String] Current rotation key
|
|
171
|
+
# @api private
|
|
172
|
+
def rotation_key_memory
|
|
173
|
+
rotation_keys = self.class.rotation_keys_store
|
|
174
|
+
|
|
175
|
+
now_seconds = Time.now.utc.to_i
|
|
176
|
+
|
|
177
|
+
# Quantize to rotation period boundary (e.g., midnight UTC for 24-hour period)
|
|
178
|
+
seconds_since_epoch = now_seconds % @hash_rotation_period
|
|
179
|
+
rotation_timestamp = now_seconds - seconds_since_epoch
|
|
180
|
+
|
|
181
|
+
# Atomically get or create key for this rotation period
|
|
182
|
+
# Use compute_if_absent for thread-safe atomic operation
|
|
183
|
+
key = rotation_keys.compute_if_absent(rotation_timestamp) do
|
|
184
|
+
# Generate new key atomically
|
|
185
|
+
# IMPORTANT: Don't modify the map inside this block to avoid deadlock
|
|
186
|
+
SecureRandom.hex(32)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Clean up old keys after atomic operation completes
|
|
190
|
+
# This runs outside compute_if_absent to avoid deadlock
|
|
191
|
+
if rotation_keys.size > 1
|
|
192
|
+
rotation_keys.each_key do |ts|
|
|
193
|
+
rotation_keys.delete(ts) if ts != rotation_timestamp
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
key
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|