otto 2.5.0 → 2.7.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -6
@@ -27,8 +27,30 @@ class Otto
27
27
  class Config
28
28
  include Otto::Core::Freezable
29
29
 
30
+ # Named privacy profiles: validated presets over the individual knobs,
31
+ # so a deployment's observability posture is declared in one reviewable
32
+ # word instead of inferred from knob combinations.
33
+ #
34
+ # - :anonymous — mask every IP, including private/localhost. For
35
+ # deployments where even internal addresses are treated as PII.
36
+ # - :masked — the default posture: public IPs masked, private and
37
+ # localhost exempt (development-friendly privacy-by-default).
38
+ # - :audit — privacy disabled: real IPs flow to env and logs. For
39
+ # private/compliance environments where granular attributability
40
+ # supersedes IP privacy; retention responsibility transfers to the
41
+ # operator.
42
+ #
43
+ # Note the axis this controls: what PERSISTS observably (env keys, logs,
44
+ # fingerprints). Precise ephemeral matching against the unmasked IP does
45
+ # not require :audit — see EnvKeys::IP_MATCH, available in every profile.
46
+ PROFILES = {
47
+ anonymous: { disabled: false, mask_private_ips: true }.freeze,
48
+ masked: { disabled: false, mask_private_ips: false }.freeze,
49
+ audit: { disabled: true }.freeze,
50
+ }.freeze
51
+
30
52
  attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
31
- attr_reader :disabled
53
+ attr_reader :disabled, :correlation_secret, :geo_header, :geo_db_path
32
54
 
33
55
  # Class-level rotation key storage (mutable, not frozen with instances)
34
56
  # This is stored at the class level so it persists across frozen config instances
@@ -49,16 +71,227 @@ class Otto
49
71
  # @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
50
72
  # @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
51
73
  # @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
74
+ # @option options [String] :geo_header Trusted, app-configured request header to read the
75
+ # country code from FIRST (before the built-in CDN provider headers). Accepts either
76
+ # the HTTP form ('X-Client-Country') or the Rack CGI form ('HTTP_X_CLIENT_COUNTRY');
77
+ # both canonicalize to the 'HTTP_*' env key. Default nil (no app-configured header).
78
+ # @option options [String] :geo_db_path Filesystem path to a MaxMind-format (.mmdb)
79
+ # country database used as the local IP->country fallback (looked up on the already
80
+ # MASKED IP). Requires the 'maxmind-db' gem. A bad/unreadable path raises at boot,
81
+ # not per-request. Default nil (no local database fallback).
82
+ # @option options [#get] :geo_db_reader Bring-your-own MMDB reader (any object responding
83
+ # to #get, e.g. a MaxMind::DB or a compatible reader). Overrides :geo_db_path when set,
84
+ # so the reader choice stays independent of Otto. Default nil.
52
85
  # @option options [Boolean] :disabled Disable privacy entirely (default: false)
53
86
  # @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
87
+ # @option options [String] :correlation_secret A secret string that turns
88
+ # on IP correlation. Default nil, meaning off.
89
+ #
90
+ # It answers one question: "are these two requests, maybe months apart,
91
+ # from the same visitor?" — without your app ever seeing the real IP.
92
+ #
93
+ # Otto masks each IP before your app runs (203.0.113.42 becomes
94
+ # 203.0.113.0), which is too coarse to tell visitors apart. When a secret
95
+ # is set, Otto also fingerprints the full IP, before masking, and hands
96
+ # your app just the fingerprint as req.ip_correlation_hash. The same IP
97
+ # always produces the same fingerprint, and it can't be turned back into
98
+ # an IP without the secret.
99
+ #
100
+ # Keep the secret stable — changing it changes every fingerprint. An empty
101
+ # string is rejected, because an empty secret would let anyone reverse the
102
+ # fingerprint back to an IP.
54
103
  # @option options [Redis] :redis Optional Redis connection for multi-server environments
104
+ # @option options [Symbol, String] :profile Named privacy profile (:anonymous,
105
+ # :masked, or :audit) applied as a preset; any other explicitly passed
106
+ # option overrides the preset. See {PROFILES}.
55
107
  def initialize(options = {})
108
+ options = self.class.profile_presets(options[:profile]).merge(options) unless options[:profile].nil?
56
109
  @octet_precision = options.fetch(:octet_precision, 1)
57
110
  @hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
58
111
  @geo_enabled = options.fetch(:geo_enabled, true)
59
112
  @disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
60
113
  @mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
114
+ self.correlation_secret = options.fetch(:correlation_secret, nil) # Opt-in stable IP-correlation secret
61
115
  @redis = options[:redis] # Optional Redis connection for multi-server environments
116
+
117
+ # Geo-location fallback configuration (all opt-in, boot-time only).
118
+ @geo_db_reader = nil # effective MMDB reader (built from path or injected)
119
+ @geo_db_override = nil # reader injected via geo_db_reader= (wins over path)
120
+ self.geo_header = options[:geo_header] # canonicalized to an HTTP_* env key (or nil)
121
+ self.geo_db_reader = options[:geo_db_reader] if options.key?(:geo_db_reader)
122
+ @geo_db_path = normalize_geo_db_path(options[:geo_db_path])
123
+ load_geo_database! # build/attach the reader now so a bad path fails at boot
124
+ end
125
+
126
+ # Set the stable correlation secret, validating its type up front.
127
+ #
128
+ # nil or an empty string mean "correlation hash disabled" (see
129
+ # IPPrivacyMiddleware#correlation_hash — an empty key is never used to
130
+ # hash). Any other non-String is a configuration error: without this
131
+ # guard it would surface far from its cause, as a NoMethodError on
132
+ # `#empty?` deep inside per-request middleware. Fail fast here instead,
133
+ # at the point of misconfiguration, with a message that names the type.
134
+ #
135
+ # @param value [String, nil] stable secret, or nil/"" to disable
136
+ # @raise [ArgumentError] if value is neither a String nor nil
137
+ def correlation_secret=(value)
138
+ unless value.nil? || value.is_a?(String)
139
+ raise ArgumentError, "correlation_secret must be a String or nil, got: #{value.class}"
140
+ end
141
+
142
+ @correlation_secret = value
143
+ end
144
+
145
+ # Set the trusted, app-configured geo header.
146
+ #
147
+ # Canonicalizes to a Rack CGI env key ('HTTP_*'): 'X-Client-Country',
148
+ # 'x-client-country' and 'HTTP_X_CLIENT_COUNTRY' all become
149
+ # 'HTTP_X_CLIENT_COUNTRY'. nil / blank clears it. Rack 3's lowercase rule
150
+ # applies to RESPONSE headers; request headers remain 'HTTP_*' env keys,
151
+ # so this is the correct form to read from the request env.
152
+ #
153
+ # @param value [String, nil] header name in either HTTP or CGI form
154
+ def geo_header=(value)
155
+ @geo_header = self.class.canonicalize_geo_header(value)
156
+ end
157
+
158
+ # Set the geo database path (MMDB file) used for the local fallback.
159
+ #
160
+ # Does not build the reader on its own — call {#load_geo_database!} (which
161
+ # {Otto::Privacy::Core#configure_ip_privacy} does for you) so a bad path
162
+ # fails at boot rather than per-request.
163
+ #
164
+ # @param value [String, nil] filesystem path to a .mmdb file, or nil
165
+ def geo_db_path=(value)
166
+ @geo_db_path = normalize_geo_db_path(value)
167
+ end
168
+
169
+ # Inject a ready-made MMDB reader (any object responding to #get).
170
+ #
171
+ # This keeps the reader choice independent of Otto (MaxMind::DB, yhirose's
172
+ # maxminddb, or a custom object all work) and is the seam used by tests.
173
+ # When set, it takes precedence over {#geo_db_path}. Passing nil clears the
174
+ # override. Takes effect on the next {#load_geo_database!}.
175
+ #
176
+ # @param reader [#get, nil] MMDB-compatible reader, or nil to clear
177
+ # @raise [ArgumentError] if reader does not respond to :get
178
+ def geo_db_reader=(reader)
179
+ unless reader.nil? || reader.respond_to?(:get)
180
+ raise ArgumentError, "geo_db_reader must respond to :get, got: #{reader.class}"
181
+ end
182
+
183
+ @geo_db_override = reader
184
+ end
185
+
186
+ # The effective MMDB reader for this config, or nil.
187
+ #
188
+ # Returns nil when geo is disabled (so `geo: false` consults no database
189
+ # even if one was previously configured) or when neither a reader override
190
+ # nor a database path is set.
191
+ #
192
+ # The reader is a plain instance variable — no class-level store. A
193
+ # MaxMind::DB reader computes its IPv4 start node eagerly at construction
194
+ # and performs no instance mutation on #get, so it is thread-safe under
195
+ # concurrency and unaffected by the shallow freeze deep_freeze! applies to
196
+ # it. That removes the only reason to hold it off-instance, and avoids an
197
+ # unbounded, never-evicted process-lifetime cache — important for a
198
+ # long-running server.
199
+ #
200
+ # @return [#get, nil] the reader, or nil
201
+ def geo_db_reader
202
+ @geo_enabled ? @geo_db_reader : nil
203
+ end
204
+
205
+ # Build/attach the geo database reader for the current configuration.
206
+ #
207
+ # Boot-time only. Resolves the effective reader (injected override wins
208
+ # over a path). A String path is opened eagerly here so an unreadable path
209
+ # or a missing 'maxmind-db' gem raises now, at configuration time, rather
210
+ # than on the first request that needs a lookup. When geo is disabled, no
211
+ # database is loaded and no reader is retained.
212
+ #
213
+ # @return [void]
214
+ # @raise [ArgumentError] if the path is unreadable or maxmind-db is absent
215
+ def load_geo_database!
216
+ @geo_db_reader = nil
217
+ return unless @geo_enabled
218
+
219
+ @geo_db_reader =
220
+ if @geo_db_override
221
+ @geo_db_override
222
+ elsif @geo_db_path
223
+ build_maxmind_reader(@geo_db_path)
224
+ end
225
+ end
226
+
227
+ # Look up the preset hash for a named profile, failing fast on typos.
228
+ #
229
+ # @param profile [Symbol, String] one of the {PROFILES} keys
230
+ # @return [Hash] frozen preset hash
231
+ # @raise [ArgumentError] for an unknown profile name or an un-nameable type
232
+ def self.profile_presets(profile)
233
+ # Neither Integer nor NilClass responds to #to_sym, so an unguarded
234
+ # conversion raises NoMethodError for `profile: 123` or an explicit
235
+ # `profile: nil` — an opaque failure inconsistent with the ArgumentError
236
+ # the rest of this class raises for bad input (cf. correlation_secret=).
237
+ unless profile.respond_to?(:to_sym)
238
+ raise ArgumentError,
239
+ "Privacy profile must be a Symbol or String, got: #{profile.class}"
240
+ end
241
+
242
+ PROFILES.fetch(profile.to_sym) do
243
+ raise ArgumentError,
244
+ "Unknown privacy profile: #{profile.inspect} (valid: #{PROFILES.keys.join(', ')})"
245
+ end
246
+ end
247
+
248
+ # Apply a named privacy profile's presets to this config.
249
+ #
250
+ # Sets only the knobs the profile names (see {PROFILES}); other settings
251
+ # (octet_precision, geo, correlation_secret, ...) are untouched.
252
+ #
253
+ # Presets are applied, not reset: a knob a profile does not name keeps its
254
+ # previous value. Switching :anonymous -> :audit therefore leaves
255
+ # mask_private_ips true, because :audit names only `disabled`. That is
256
+ # inert rather than wrong — `disabled` short-circuits privacy_enabled?
257
+ # before mask_private_ips is ever read, and #profile below tests @disabled
258
+ # first, so the derived label stays accurate. Switching on to :masked
259
+ # re-sets both knobs explicitly. Only surprising if you read the raw ivars.
260
+ #
261
+ # @param profile [Symbol, String] :anonymous, :masked, or :audit
262
+ # @raise [ArgumentError] for an unknown profile name
263
+ def profile=(profile)
264
+ presets = self.class.profile_presets(profile)
265
+ @disabled = presets[:disabled] if presets.key?(:disabled)
266
+ @mask_private_ips = presets[:mask_private_ips] if presets.key?(:mask_private_ips)
267
+ end
268
+
269
+ # The profile the current knob state corresponds to.
270
+ #
271
+ # Derived from the live settings rather than remembering the last
272
+ # `profile=` call, so manual knob changes can never leave a stale label:
273
+ # what this returns is always what the config actually does.
274
+ #
275
+ # @return [Symbol] :audit, :anonymous, or :masked
276
+ def profile
277
+ return :audit if @disabled
278
+ return :anonymous if @mask_private_ips
279
+
280
+ :masked
281
+ end
282
+
283
+ # Canonicalize a geo header name to a Rack CGI env key ('HTTP_*').
284
+ #
285
+ # @param value [String, nil] header in HTTP ('X-Client-Country') or CGI form
286
+ # @return [String, nil] 'HTTP_*' env key, or nil for nil/blank input
287
+ def self.canonicalize_geo_header(value)
288
+ return nil if value.nil?
289
+
290
+ key = value.to_s.strip
291
+ return nil if key.empty?
292
+
293
+ key = key.upcase.tr('-', '_')
294
+ key.start_with?('HTTP_') ? key : "HTTP_#{key}"
62
295
  end
63
296
 
64
297
  # Check if privacy is enabled
@@ -124,13 +357,58 @@ class Otto
124
357
  raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
125
358
  2].include?(@octet_precision)
126
359
 
127
- return unless @hash_rotation_period < 60
360
+ # Type check before the numeric comparison: a non-Numeric value (false,
361
+ # a String from unparsed config, ...) would otherwise surface as
362
+ # NoMethodError/ArgumentError from #<, not a clear configuration error.
363
+ return if @hash_rotation_period.is_a?(Numeric) && @hash_rotation_period >= 60
128
364
 
129
- raise ArgumentError, 'hash_rotation_period must be at least 60 seconds'
365
+ raise ArgumentError,
366
+ "hash_rotation_period must be at least 60 seconds, got: #{@hash_rotation_period.inspect}"
130
367
  end
131
368
 
132
369
  private
133
370
 
371
+ # Normalize a geo_db_path option to a non-empty String or nil.
372
+ #
373
+ # @param value [String, nil] raw path option
374
+ # @return [String, nil]
375
+ def normalize_geo_db_path(value)
376
+ return nil if value.nil?
377
+
378
+ path = value.to_s.strip
379
+ path.empty? ? nil : path
380
+ end
381
+
382
+ # Open an MMDB file into an in-memory reader, failing fast on problems.
383
+ #
384
+ # The 'maxmind-db' gem is an OPTIONAL dependency: it is required lazily
385
+ # here, only when a database path is actually configured, so Otto stays
386
+ # dependency-light for the (common) header-only geo setups. Callers who
387
+ # prefer a different reader can inject one via {#geo_db_reader=} and never
388
+ # trigger this path.
389
+ #
390
+ # @param path [String] filesystem path to a .mmdb file
391
+ # @return [MaxMind::DB] in-memory reader
392
+ # @raise [ArgumentError] if the path is unreadable or the gem is missing
393
+ def build_maxmind_reader(path)
394
+ raise ArgumentError, "geo_db_path is not readable: #{path.inspect}" unless File.readable?(path)
395
+
396
+ begin
397
+ require 'maxmind/db'
398
+ rescue LoadError
399
+ raise ArgumentError,
400
+ "geo_db_path is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
401
+ "Add `gem 'maxmind-db'` to your Gemfile, or inject your own reader via " \
402
+ 'configure_ip_privacy(geo_db_reader: ...).'
403
+ end
404
+
405
+ begin
406
+ MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
407
+ rescue StandardError => e
408
+ raise ArgumentError, "Failed to open geo_db_path #{path.inspect}: #{e.class}: #{e.message}"
409
+ end
410
+ end
411
+
134
412
  # Redis-based rotation key (atomic across multiple servers)
135
413
  #
136
414
  # Uses SET NX GET EX to atomically:
@@ -50,8 +50,27 @@ class Otto
50
50
  #
51
51
  # @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
52
52
  # @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
53
- # @param geo [Boolean] Enable geo-location resolution (default: true)
53
+ # @param geo [Boolean] Enable geo-location resolution (default: true). When
54
+ # false, geo short-circuits entirely: no headers are read and no database
55
+ # is loaded or consulted.
56
+ # @param geo_header [String] Trusted, app-configured request header checked
57
+ # FIRST for the country code (e.g. 'X-Client-Country'). Accepts the HTTP
58
+ # or 'HTTP_*' CGI form; both canonicalize to the env key. Pass '' to clear.
59
+ # @param geo_db_path [String] Path to a MaxMind-format (.mmdb) country
60
+ # database for the local IP->country fallback (looked up on the MASKED
61
+ # IP). Requires the optional 'maxmind-db' gem. A bad path raises at boot,
62
+ # not per-request. Pass '' to clear.
63
+ # @param geo_db_reader [#get] Bring-your-own MMDB reader (any object
64
+ # responding to #get); overrides geo_db_path. Omitted/nil leaves any
65
+ # existing reader unchanged; use geo: false to stop consulting a database.
54
66
  # @param redis [Redis] Redis connection for multi-server atomic key generation
67
+ # @param correlation_secret [String] A secret string that turns on IP
68
+ # correlation: it lets you tell whether two requests, even months apart,
69
+ # came from the same visitor — without your app ever seeing the real IP.
70
+ # (Otto masks the IP before your app runs; with a secret set it also
71
+ # fingerprints the full IP into req.ip_correlation_hash, which can't be
72
+ # reversed to an IP without the secret.) Omit it to leave any existing
73
+ # secret unchanged; pass an empty string to turn the feature back off.
55
74
  #
56
75
  # @example Mask 2 octets instead of 1
57
76
  # otto.configure_ip_privacy(octet_precision: 2)
@@ -62,20 +81,97 @@ class Otto
62
81
  # @example Custom hash rotation
63
82
  # otto.configure_ip_privacy(hash_rotation: 24.hours)
64
83
  #
84
+ # @example Enable stable IP correlation (same visitor across days)
85
+ # otto.configure_ip_privacy(correlation_secret: ENV['IP_CORRELATION_SECRET'])
86
+ #
65
87
  # @example Multi-server with Redis
66
88
  # redis = Redis.new(url: ENV['REDIS_URL'])
67
89
  # otto.configure_ip_privacy(redis: redis)
68
- def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
90
+ #
91
+ # @example Declare the observability posture for a compliance deployment
92
+ # otto.configure_ip_privacy(profile: :audit)
93
+ #
94
+ # @param profile [Symbol] Named privacy profile (:anonymous, :masked, or
95
+ # :audit) applied FIRST as a preset over disabled/mask_private_ips, so
96
+ # any other option in the same call overrides it. This declares the
97
+ # deployment's observability posture in one reviewable word; see
98
+ # Otto::Privacy::Config::PROFILES.
99
+ #
100
+ # rubocop:disable Metrics/ParameterLists -- a keyword-only configuration
101
+ # method; the options are self-documenting at the call site and grouping
102
+ # them into a hash would only obscure the supported settings.
103
+ def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil,
104
+ correlation_secret: nil, geo_header: nil, geo_db_path: nil,
105
+ geo_db_reader: nil, profile: nil)
106
+ # rubocop:enable Metrics/ParameterLists
69
107
  ensure_not_frozen!
70
108
  config = @security_config.ip_privacy_config
109
+ knobs = { profile: profile, octet_precision: octet_precision,
110
+ hash_rotation: hash_rotation, geo: geo,
111
+ correlation_secret: correlation_secret, redis: redis }
112
+
113
+ # Dry-run the assignments on a throwaway copy and validate the combined
114
+ # result there, so a rejected knob (octet_precision: 7 after a profile
115
+ # preset, say) raises before the live config has been touched — the
116
+ # call is all-or-nothing, never half-applied.
117
+ apply_privacy_knobs(config.dup, knobs).validate!
118
+ apply_privacy_knobs(config, knobs)
119
+
120
+ apply_geo_config(config, geo: geo, geo_header: geo_header,
121
+ geo_db_path: geo_db_path, geo_db_reader: geo_db_reader)
122
+ end
123
+
124
+ private
125
+
126
+ # Assign the non-geo privacy knobs onto a config.
127
+ #
128
+ # Every kwarg uses a nil guard: nil means "leave unchanged", any other
129
+ # value — including false or "" — is a real assignment that must either
130
+ # take effect or fail validation loudly. A truthiness guard would
131
+ # silently drop false (and, for correlation_secret, the explicit ""
132
+ # that disables the correlation hash).
133
+ #
134
+ # @param config [Otto::Privacy::Config] the config to mutate
135
+ # @param knobs [Hash] the non-geo keyword arguments from configure_ip_privacy
136
+ # @return [Otto::Privacy::Config] the same config, for chaining
137
+ # @api private
138
+ def apply_privacy_knobs(config, knobs)
139
+ config.profile = knobs[:profile] unless knobs[:profile].nil?
140
+ config.octet_precision = knobs[:octet_precision] unless knobs[:octet_precision].nil?
141
+ config.hash_rotation_period = knobs[:hash_rotation] unless knobs[:hash_rotation].nil?
142
+ config.geo_enabled = knobs[:geo] unless knobs[:geo].nil?
143
+ config.correlation_secret = knobs[:correlation_secret] unless knobs[:correlation_secret].nil?
144
+ config.instance_variable_set(:@redis, knobs[:redis]) unless knobs[:redis].nil?
145
+ config
146
+ end
147
+
148
+ # Apply the geo-fallback settings and (re)load the database when needed.
149
+ #
150
+ # nil means "leave unchanged"; '' clears a header or path. Any geo-affecting
151
+ # change triggers a boot-time (re)load so a bad geo_db_path fails here, not
152
+ # on the first request that needs a lookup.
153
+ #
154
+ # @param config [Otto::Privacy::Config] the privacy config to mutate
155
+ # @api private
156
+ def apply_geo_config(config, geo:, geo_header:, geo_db_path:, geo_db_reader:)
157
+ geo_touched = [geo, geo_header, geo_db_path, geo_db_reader].any? { |v| !v.nil? }
158
+
159
+ config.geo_header = geo_header unless geo_header.nil?
71
160
 
72
- config.octet_precision = octet_precision if octet_precision
73
- config.hash_rotation_period = hash_rotation if hash_rotation
74
- config.geo_enabled = geo unless geo.nil?
75
- config.instance_variable_set(:@redis, redis) if redis
161
+ # A newly supplied reader or path replaces the other database source. A
162
+ # reader given in this call wins over a path (documented precedence); a
163
+ # path given on its own clears any prior injected reader so it actually
164
+ # takes effect — otherwise the stale override would silently shadow the
165
+ # new path (leaving lookups pointed at a closed/old reader).
166
+ if !geo_db_reader.nil?
167
+ config.geo_db_reader = geo_db_reader
168
+ config.geo_db_path = geo_db_path unless geo_db_path.nil?
169
+ elsif !geo_db_path.nil?
170
+ config.geo_db_reader = nil
171
+ config.geo_db_path = geo_db_path
172
+ end
76
173
 
77
- # Validate configuration
78
- config.validate!
174
+ config.load_geo_database! if geo_touched
79
175
  end
80
176
  end
81
177
  end