otto 2.0.0.pre2 → 2.0.0.pre3
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 +0 -2
- data/.github/workflows/claude-code-review.yml +29 -13
- data/CLAUDE.md +537 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +17 -10
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -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/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +89 -39
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +24 -17
- data/lib/otto/core/router.rb +1 -1
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +8 -4
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +3 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +16 -14
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
- data/lib/otto/security/authentication.rb +3 -4
- data/lib/otto/security/config.rb +51 -7
- data/lib/otto/security/configurator.rb +0 -13
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +181 -86
- data/otto.gemspec +3 -0
- metadata +58 -3
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/core/freezable.rb
|
|
4
|
+
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Core
|
|
9
|
+
# Provides deep freezing capability for configuration objects
|
|
10
|
+
#
|
|
11
|
+
# This module enables objects to be deeply frozen, preventing any
|
|
12
|
+
# modifications to the object itself and all its nested structures.
|
|
13
|
+
# This is critical for security as it prevents runtime tampering with
|
|
14
|
+
# security configurations.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# class MyConfig
|
|
18
|
+
# include Otto::Core::Freezable
|
|
19
|
+
#
|
|
20
|
+
# def initialize
|
|
21
|
+
# @settings = { security: { enabled: true } }
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# config = MyConfig.new
|
|
26
|
+
# config.deep_freeze!
|
|
27
|
+
# # Now config and all nested hashes/arrays are frozen
|
|
28
|
+
#
|
|
29
|
+
module Freezable
|
|
30
|
+
# Deeply freeze this object and all its instance variables
|
|
31
|
+
#
|
|
32
|
+
# This method recursively freezes all nested structures including:
|
|
33
|
+
# - Hashes (both keys and values)
|
|
34
|
+
# - Arrays (and all elements)
|
|
35
|
+
# - Sets
|
|
36
|
+
# - Other freezable objects
|
|
37
|
+
#
|
|
38
|
+
# NOTE: This method is idempotent and safe to call multiple times.
|
|
39
|
+
#
|
|
40
|
+
# @return [self] The frozen object
|
|
41
|
+
def deep_freeze!
|
|
42
|
+
return self if frozen?
|
|
43
|
+
|
|
44
|
+
freeze_instance_variables!
|
|
45
|
+
freeze
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Freeze all instance variables recursively
|
|
52
|
+
def freeze_instance_variables!
|
|
53
|
+
instance_variables.each do |var|
|
|
54
|
+
value = instance_variable_get(var)
|
|
55
|
+
deep_freeze_value(value)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Recursively freeze a value based on its type
|
|
60
|
+
#
|
|
61
|
+
# @param value [Object] Value to freeze
|
|
62
|
+
# @return [void]
|
|
63
|
+
def deep_freeze_value(value)
|
|
64
|
+
case value
|
|
65
|
+
when Hash
|
|
66
|
+
# Freeze hash keys and values, then freeze the hash itself
|
|
67
|
+
value.each do |k, v|
|
|
68
|
+
k.freeze unless k.frozen?
|
|
69
|
+
deep_freeze_value(v)
|
|
70
|
+
end
|
|
71
|
+
value.freeze
|
|
72
|
+
when Array
|
|
73
|
+
# Freeze all array elements, then freeze the array
|
|
74
|
+
value.each { |item| deep_freeze_value(item) }
|
|
75
|
+
value.freeze
|
|
76
|
+
when Set
|
|
77
|
+
# Sets are immutable once frozen
|
|
78
|
+
value.freeze
|
|
79
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
|
|
80
|
+
# These types are either immutable or already frozen
|
|
81
|
+
value.freeze if value.respond_to?(:freeze) && !value.frozen?
|
|
82
|
+
else
|
|
83
|
+
# For other objects, recursively freeze if they support it, otherwise shallow freeze.
|
|
84
|
+
if value.respond_to?(:deep_freeze!)
|
|
85
|
+
value.deep_freeze!
|
|
86
|
+
elsif value.respond_to?(:freeze) && !value.frozen?
|
|
87
|
+
value.freeze
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# lib/otto/core/middleware_stack.rb
|
|
4
4
|
|
|
5
|
+
require_relative 'freezable'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
module Core
|
|
7
9
|
# Enhanced middleware stack management for Otto framework.
|
|
@@ -9,10 +11,18 @@ class Otto
|
|
|
9
11
|
# and improved execution chain management.
|
|
10
12
|
class MiddlewareStack
|
|
11
13
|
include Enumerable
|
|
14
|
+
include Otto::Core::Freezable
|
|
12
15
|
|
|
13
16
|
def initialize
|
|
14
17
|
@stack = []
|
|
15
18
|
@middleware_set = Set.new
|
|
19
|
+
@on_change_callback = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Set a callback to be invoked when the middleware stack changes
|
|
23
|
+
# @param callback [Proc] A callable object (e.g., method or lambda)
|
|
24
|
+
def on_change(&callback)
|
|
25
|
+
@on_change_callback = callback
|
|
16
26
|
end
|
|
17
27
|
|
|
18
28
|
# Enhanced middleware registration with argument uniqueness and immutability check
|
|
@@ -33,8 +43,8 @@ class Otto
|
|
|
33
43
|
entry = { middleware: middleware_class, args: args, options: options }
|
|
34
44
|
@stack << entry
|
|
35
45
|
@middleware_set.add(middleware_class)
|
|
36
|
-
#
|
|
37
|
-
@
|
|
46
|
+
# Notify of change
|
|
47
|
+
@on_change_callback&.call
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
# Add middleware with position hint for optimal ordering
|
|
@@ -67,7 +77,8 @@ class Otto
|
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
@middleware_set.add(middleware_class)
|
|
70
|
-
|
|
80
|
+
# Notify of change
|
|
81
|
+
@on_change_callback&.call
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
# Validate MCP middleware ordering
|
|
@@ -131,8 +142,8 @@ class Otto
|
|
|
131
142
|
|
|
132
143
|
# Rebuild the set of unique middleware classes
|
|
133
144
|
@middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
|
|
134
|
-
#
|
|
135
|
-
@
|
|
145
|
+
# Notify of change
|
|
146
|
+
@on_change_callback&.call
|
|
136
147
|
end
|
|
137
148
|
|
|
138
149
|
# Check if middleware is registered - now O(1) using Set
|
|
@@ -147,8 +158,8 @@ class Otto
|
|
|
147
158
|
|
|
148
159
|
@stack.clear
|
|
149
160
|
@middleware_set.clear
|
|
150
|
-
#
|
|
151
|
-
@
|
|
161
|
+
# Notify of change
|
|
162
|
+
@on_change_callback&.call
|
|
152
163
|
end
|
|
153
164
|
|
|
154
165
|
# Enumerable support
|
|
@@ -157,7 +168,7 @@ class Otto
|
|
|
157
168
|
end
|
|
158
169
|
|
|
159
170
|
# Build Rack application with middleware chain
|
|
160
|
-
def
|
|
171
|
+
def wrap(base_app, security_config = nil)
|
|
161
172
|
@stack.reduce(base_app) do |app, entry|
|
|
162
173
|
middleware = entry[:middleware]
|
|
163
174
|
args = entry[:args]
|
|
@@ -177,10 +188,9 @@ class Otto
|
|
|
177
188
|
end
|
|
178
189
|
end
|
|
179
190
|
|
|
180
|
-
#
|
|
191
|
+
# Returns list of middleware classes in order
|
|
181
192
|
def middleware_list
|
|
182
|
-
|
|
183
|
-
@memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
|
|
193
|
+
@stack.map { |entry| entry[:middleware] }
|
|
184
194
|
end
|
|
185
195
|
|
|
186
196
|
# Detailed introspection
|
|
@@ -215,6 +225,8 @@ class Otto
|
|
|
215
225
|
@stack.reverse_each(&)
|
|
216
226
|
end
|
|
217
227
|
|
|
228
|
+
|
|
229
|
+
|
|
218
230
|
private
|
|
219
231
|
|
|
220
232
|
def middleware_needs_config?(middleware_class)
|
|
@@ -224,12 +236,7 @@ class Otto
|
|
|
224
236
|
Otto::Security::Middleware::CSRFMiddleware,
|
|
225
237
|
Otto::Security::Middleware::ValidationMiddleware,
|
|
226
238
|
Otto::Security::Middleware::RateLimitMiddleware,
|
|
227
|
-
Otto::Security::
|
|
228
|
-
# Backward compatibility aliases
|
|
229
|
-
Otto::Security::CSRFMiddleware,
|
|
230
|
-
Otto::Security::ValidationMiddleware,
|
|
231
|
-
Otto::Security::RateLimitMiddleware,
|
|
232
|
-
Otto::Security::AuthenticationMiddleware,
|
|
239
|
+
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
233
240
|
].include?(middleware_class)
|
|
234
241
|
end
|
|
235
242
|
end
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -51,7 +51,7 @@ class Otto
|
|
|
51
51
|
def handle_request(env)
|
|
52
52
|
locale = determine_locale env
|
|
53
53
|
env['rack.locale'] = locale
|
|
54
|
-
env['otto.locale_config'] = @locale_config if @locale_config
|
|
54
|
+
env['otto.locale_config'] = @locale_config.to_h if @locale_config
|
|
55
55
|
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
|
56
56
|
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
|
57
57
|
path_info = '/' if path_info.to_s.empty?
|
data/lib/otto/core.rb
ADDED
data/lib/otto/env_keys.rb
CHANGED
|
@@ -36,21 +36,25 @@ class Otto
|
|
|
36
36
|
|
|
37
37
|
# Authentication strategy result containing session/user state
|
|
38
38
|
# Type: Otto::Security::Authentication::StrategyResult
|
|
39
|
-
# Set by:
|
|
39
|
+
# Set by: RouteAuthWrapper (wraps all route handlers)
|
|
40
40
|
# Used by: RouteHandlers, LogicClasses, Controllers
|
|
41
|
-
#
|
|
41
|
+
# Guarantee: ALWAYS present - either authenticated or anonymous
|
|
42
|
+
# - Routes WITH auth requirement: Authenticated StrategyResult or 401/302
|
|
43
|
+
# - Routes WITHOUT auth requirement: Anonymous StrategyResult
|
|
42
44
|
STRATEGY_RESULT = 'otto.strategy_result'
|
|
43
45
|
|
|
44
46
|
# Authenticated user object (convenience accessor)
|
|
45
47
|
# Type: Hash, Custom User Object, or nil
|
|
46
|
-
# Set by:
|
|
48
|
+
# Set by: RouteAuthWrapper (from strategy_result.user)
|
|
47
49
|
# Used by: Controllers, RouteHandlers
|
|
50
|
+
# Note: nil for anonymous/unauthenticated requests
|
|
48
51
|
USER = 'otto.user'
|
|
49
52
|
|
|
50
53
|
# User-specific context (session, roles, permissions, etc.)
|
|
51
54
|
# Type: Hash
|
|
52
|
-
# Set by:
|
|
55
|
+
# Set by: RouteAuthWrapper (from strategy_result.user_context)
|
|
53
56
|
# Used by: Controllers, Analytics
|
|
57
|
+
# Note: Empty hash {} for anonymous requests
|
|
54
58
|
USER_CONTEXT = 'otto.user_context'
|
|
55
59
|
|
|
56
60
|
# =========================================================================
|
data/lib/otto/helpers/request.rb
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/helpers/request.rb
|
|
4
2
|
|
|
5
3
|
require_relative 'base'
|
|
@@ -13,6 +11,86 @@ class Otto
|
|
|
13
11
|
env['HTTP_USER_AGENT']
|
|
14
12
|
end
|
|
15
13
|
|
|
14
|
+
# NOTE: We do NOT override Rack::Request#ip
|
|
15
|
+
#
|
|
16
|
+
# IPPrivacyMiddleware masks both REMOTE_ADDR and X-Forwarded-For headers,
|
|
17
|
+
# so Rack's native ip resolution logic works correctly with masked values.
|
|
18
|
+
# This allows Rack to handle proxy scenarios (trusted proxies, header parsing)
|
|
19
|
+
# while still returning privacy-safe masked IPs.
|
|
20
|
+
#
|
|
21
|
+
# If you need the masked IP explicitly, use:
|
|
22
|
+
# req.masked_ip # => '192.168.1.0' or nil if privacy disabled
|
|
23
|
+
#
|
|
24
|
+
# If you need the geo country:
|
|
25
|
+
# req.geo_country # => 'US' or nil
|
|
26
|
+
#
|
|
27
|
+
# If you need the full privacy fingerprint:
|
|
28
|
+
# req.redacted_fingerprint # => RedactedFingerprint object or nil
|
|
29
|
+
|
|
30
|
+
# Get the privacy-safe fingerprint for this request
|
|
31
|
+
#
|
|
32
|
+
# Returns nil if IP privacy is disabled. The fingerprint contains
|
|
33
|
+
# anonymized request information suitable for logging and analytics.
|
|
34
|
+
#
|
|
35
|
+
# @return [Otto::Privacy::RedactedFingerprint, nil] Privacy-safe fingerprint
|
|
36
|
+
# @example
|
|
37
|
+
# fingerprint = req.redacted_fingerprint
|
|
38
|
+
# fingerprint.masked_ip # => '192.168.1.0'
|
|
39
|
+
# fingerprint.country # => 'US'
|
|
40
|
+
def redacted_fingerprint
|
|
41
|
+
env['otto.redacted_fingerprint']
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get the geo-location country code for the request
|
|
45
|
+
#
|
|
46
|
+
# Returns ISO 3166-1 alpha-2 country code or 'XX' for unknown.
|
|
47
|
+
# Only available when IP privacy is enabled (default).
|
|
48
|
+
#
|
|
49
|
+
# @return [String, nil] Country code or nil if privacy disabled
|
|
50
|
+
# @example
|
|
51
|
+
# req.geo_country # => 'US'
|
|
52
|
+
def geo_country
|
|
53
|
+
redacted_fingerprint&.country || env['otto.geo_country']
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get anonymized user agent string
|
|
57
|
+
#
|
|
58
|
+
# Returns user agent with version numbers stripped for privacy.
|
|
59
|
+
# Only available when IP privacy is enabled (default).
|
|
60
|
+
#
|
|
61
|
+
# @return [String, nil] Anonymized user agent or nil
|
|
62
|
+
# @example
|
|
63
|
+
# req.anonymized_user_agent
|
|
64
|
+
# # => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'
|
|
65
|
+
def anonymized_user_agent
|
|
66
|
+
redacted_fingerprint&.anonymized_ua
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get masked IP address
|
|
70
|
+
#
|
|
71
|
+
# Returns privacy-safe masked IP. When privacy is enabled (default),
|
|
72
|
+
# this returns the masked version. When disabled, returns original IP.
|
|
73
|
+
#
|
|
74
|
+
# @return [String, nil] Masked or original IP address
|
|
75
|
+
# @example
|
|
76
|
+
# req.masked_ip # => '192.168.1.0'
|
|
77
|
+
def masked_ip
|
|
78
|
+
env['otto.masked_ip'] || env['REMOTE_ADDR']
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get hashed IP for session correlation
|
|
82
|
+
#
|
|
83
|
+
# Returns daily-rotating hash of the IP address, allowing session
|
|
84
|
+
# tracking without storing the original IP. Only available when
|
|
85
|
+
# IP privacy is enabled (default).
|
|
86
|
+
#
|
|
87
|
+
# @return [String, nil] Hexadecimal hash string or nil
|
|
88
|
+
# @example
|
|
89
|
+
# req.hashed_ip # => 'a3f8b2c4d5e6f7...'
|
|
90
|
+
def hashed_ip
|
|
91
|
+
redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
|
|
92
|
+
end
|
|
93
|
+
|
|
16
94
|
def client_ipaddress
|
|
17
95
|
remote_addr = env['REMOTE_ADDR']
|
|
18
96
|
|
|
@@ -14,10 +14,10 @@ class Otto
|
|
|
14
14
|
def send_secure_cookie(name, value, ttl, opts = {})
|
|
15
15
|
# Default security options
|
|
16
16
|
defaults = {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
secure: true,
|
|
18
|
+
httponly: true,
|
|
19
19
|
same_site: :strict,
|
|
20
|
-
|
|
20
|
+
path: '/',
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
# Merge with provided options
|
data/lib/otto/helpers.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/locale/config.rb
|
|
4
|
+
|
|
5
|
+
require_relative '../core/freezable'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Locale
|
|
9
|
+
# Locale configuration for Otto applications
|
|
10
|
+
#
|
|
11
|
+
# This class manages locale-related settings including available locales
|
|
12
|
+
# and default locale selection.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# config = Otto::Locale::Config.new
|
|
16
|
+
# config.available_locales = { 'en' => 'English', 'es' => 'Spanish' }
|
|
17
|
+
# config.default_locale = 'en'
|
|
18
|
+
#
|
|
19
|
+
# @example With initialization
|
|
20
|
+
# config = Otto::Locale::Config.new(
|
|
21
|
+
# available_locales: { 'en' => 'English', 'fr' => 'French' },
|
|
22
|
+
# default_locale: 'en'
|
|
23
|
+
# )
|
|
24
|
+
class Config
|
|
25
|
+
include Otto::Core::Freezable
|
|
26
|
+
|
|
27
|
+
attr_accessor :available_locales, :default_locale
|
|
28
|
+
|
|
29
|
+
# Initialize locale configuration
|
|
30
|
+
#
|
|
31
|
+
# @param available_locales [Hash, nil] Hash of locale codes to names
|
|
32
|
+
# @param default_locale [String, nil] Default locale code
|
|
33
|
+
def initialize(available_locales: nil, default_locale: nil)
|
|
34
|
+
@available_locales = available_locales
|
|
35
|
+
@default_locale = default_locale
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Convert to hash for compatibility with existing code
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Hash representation of configuration
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
available_locales: @available_locales,
|
|
44
|
+
default_locale: @default_locale,
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if locale configuration is present
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] true if either available_locales or default_locale is set
|
|
51
|
+
def configured?
|
|
52
|
+
!@available_locales.nil? || !@default_locale.nil?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/otto/mcp.rb
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# lib/otto/privacy/config.rb
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'digest'
|
|
6
|
+
|
|
7
|
+
require 'concurrent'
|
|
8
|
+
|
|
9
|
+
require_relative '../core/freezable'
|
|
10
|
+
|
|
11
|
+
class Otto
|
|
12
|
+
module Privacy
|
|
13
|
+
# Configuration for IP privacy features
|
|
14
|
+
#
|
|
15
|
+
# Privacy is ENABLED by default for public IPs. Private/localhost IPs are not masked.
|
|
16
|
+
#
|
|
17
|
+
# @example Default configuration (privacy enabled)
|
|
18
|
+
# config = Otto::Privacy::Config.new
|
|
19
|
+
# config.enabled? # => true
|
|
20
|
+
#
|
|
21
|
+
# @example Configure masking level
|
|
22
|
+
# config = Otto::Privacy::Config.new
|
|
23
|
+
# config.octet_precision = 2 # Mask 2 octets instead of 1
|
|
24
|
+
#
|
|
25
|
+
class Config
|
|
26
|
+
include Otto::Core::Freezable
|
|
27
|
+
|
|
28
|
+
attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
|
|
29
|
+
attr_reader :disabled
|
|
30
|
+
|
|
31
|
+
# Class-level rotation key storage (mutable, not frozen with instances)
|
|
32
|
+
# This is stored at the class level so it persists across frozen config instances
|
|
33
|
+
@rotation_keys_store = nil
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Get the class-level rotation keys store
|
|
37
|
+
# @return [Concurrent::Map] Thread-safe map for rotation keys
|
|
38
|
+
def rotation_keys_store
|
|
39
|
+
@rotation_keys_store = Concurrent::Map.new unless defined?(@rotation_keys_store) && @rotation_keys_store
|
|
40
|
+
@rotation_keys_store
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Initialize privacy configuration
|
|
45
|
+
#
|
|
46
|
+
# @param options [Hash] Configuration options
|
|
47
|
+
# @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
|
|
48
|
+
# @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
|
|
49
|
+
# @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
|
|
50
|
+
# @option options [Boolean] :disabled Disable privacy entirely (default: false)
|
|
51
|
+
# @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
|
|
52
|
+
# @option options [Redis] :redis Optional Redis connection for multi-server environments
|
|
53
|
+
def initialize(options = {})
|
|
54
|
+
@octet_precision = options.fetch(:octet_precision, 1)
|
|
55
|
+
@hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
|
|
56
|
+
@geo_enabled = options.fetch(:geo_enabled, true)
|
|
57
|
+
@disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
|
|
58
|
+
@mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
|
|
59
|
+
@redis = options[:redis] # Optional Redis connection for multi-server environments
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if privacy is enabled
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean] true if privacy is enabled (default)
|
|
65
|
+
def enabled?
|
|
66
|
+
!@disabled
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check if privacy is disabled
|
|
70
|
+
#
|
|
71
|
+
# @return [Boolean] true if privacy was explicitly disabled
|
|
72
|
+
def disabled?
|
|
73
|
+
@disabled
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Disable privacy (allows access to original IPs)
|
|
77
|
+
#
|
|
78
|
+
# IMPORTANT: This should only be used when you have a specific
|
|
79
|
+
# requirement to access original IP addresses. By default, Otto
|
|
80
|
+
# provides privacy-safe masked IPs.
|
|
81
|
+
#
|
|
82
|
+
# @return [self]
|
|
83
|
+
def disable!
|
|
84
|
+
@disabled = true
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Enable privacy (default state)
|
|
89
|
+
#
|
|
90
|
+
# @return [self]
|
|
91
|
+
def enable!
|
|
92
|
+
@disabled = false
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get the current rotation key for IP hashing
|
|
97
|
+
#
|
|
98
|
+
# Keys rotate at fixed intervals based on hash_rotation_period (default: 24 hours).
|
|
99
|
+
# Each rotation period gets a unique key, ensuring IP addresses hash differently
|
|
100
|
+
# across periods while remaining consistent within.
|
|
101
|
+
#
|
|
102
|
+
# Multi-server support:
|
|
103
|
+
# - With Redis: Uses SET NX GET EX for atomic key generation across all servers
|
|
104
|
+
# - Without Redis: Falls back to in-memory Concurrent::Hash (single-server only)
|
|
105
|
+
#
|
|
106
|
+
# Redis keys:
|
|
107
|
+
# - rotation_key:{timestamp} - Stores the rotation key with TTL
|
|
108
|
+
#
|
|
109
|
+
# @return [String] Current rotation key for hashing
|
|
110
|
+
def rotation_key
|
|
111
|
+
if @redis
|
|
112
|
+
rotation_key_redis
|
|
113
|
+
else
|
|
114
|
+
rotation_key_memory
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate configuration settings
|
|
119
|
+
#
|
|
120
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
121
|
+
def validate!
|
|
122
|
+
raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
|
|
123
|
+
2].include?(@octet_precision)
|
|
124
|
+
|
|
125
|
+
return unless @hash_rotation_period < 60
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, 'hash_rotation_period must be at least 60 seconds'
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Redis-based rotation key (atomic across multiple servers)
|
|
133
|
+
#
|
|
134
|
+
# Uses SET NX GET EX to atomically:
|
|
135
|
+
# 1. Check if key exists
|
|
136
|
+
# 2. Set new key only if missing
|
|
137
|
+
# 3. Return existing or newly set key
|
|
138
|
+
# 4. Auto-expire with TTL
|
|
139
|
+
#
|
|
140
|
+
# @return [String] Current rotation key
|
|
141
|
+
# @api private
|
|
142
|
+
def rotation_key_redis
|
|
143
|
+
now_seconds = Time.now.utc.to_i
|
|
144
|
+
|
|
145
|
+
# Quantize to rotation period boundary
|
|
146
|
+
rotation_timestamp = (now_seconds / @hash_rotation_period) * @hash_rotation_period
|
|
147
|
+
|
|
148
|
+
redis_key = "rotation_key:#{rotation_timestamp}"
|
|
149
|
+
ttl = (@hash_rotation_period * 1.2).to_i # Auto-cleanup with 20% buffer
|
|
150
|
+
|
|
151
|
+
key = SecureRandom.hex(32)
|
|
152
|
+
|
|
153
|
+
# SET NX GET returns old value if key exists, nil if we set it
|
|
154
|
+
# @see https://valkey.io/commands/set/
|
|
155
|
+
existing_key = @redis.set(redis_key, key, nx: true, get: true, ex: ttl)
|
|
156
|
+
|
|
157
|
+
existing_key || key
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# In-memory rotation key (single-server fallback)
|
|
161
|
+
#
|
|
162
|
+
# Uses class-level Concurrent::Hash for thread-safety within a single process.
|
|
163
|
+
# NOT atomic across multiple servers.
|
|
164
|
+
#
|
|
165
|
+
# The rotation keys are stored at the class level so they remain mutable
|
|
166
|
+
# even when config instances are frozen.
|
|
167
|
+
#
|
|
168
|
+
# @return [String] Current rotation key
|
|
169
|
+
# @api private
|
|
170
|
+
def rotation_key_memory
|
|
171
|
+
rotation_keys = self.class.rotation_keys_store
|
|
172
|
+
|
|
173
|
+
now_seconds = Time.now.utc.to_i
|
|
174
|
+
|
|
175
|
+
# Quantize to rotation period boundary (e.g., midnight UTC for 24-hour period)
|
|
176
|
+
seconds_since_epoch = now_seconds % @hash_rotation_period
|
|
177
|
+
rotation_timestamp = now_seconds - seconds_since_epoch
|
|
178
|
+
|
|
179
|
+
# Atomically get or create key for this rotation period
|
|
180
|
+
# Use compute_if_absent for thread-safe atomic operation
|
|
181
|
+
key = rotation_keys.compute_if_absent(rotation_timestamp) do
|
|
182
|
+
# Generate new key atomically
|
|
183
|
+
# IMPORTANT: Don't modify the map inside this block to avoid deadlock
|
|
184
|
+
SecureRandom.hex(32)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Clean up old keys after atomic operation completes
|
|
188
|
+
# This runs outside compute_if_absent to avoid deadlock
|
|
189
|
+
if rotation_keys.size > 1
|
|
190
|
+
rotation_keys.each_key do |ts|
|
|
191
|
+
rotation_keys.delete(ts) if ts != rotation_timestamp
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
key
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|