Class: Otto::Privacy::Config

Inherits:
Object
  • Object
show all
Includes:
Core::Freezable
Defined in:
lib/otto/privacy/config.rb

Overview

Configuration for IP privacy features

Privacy is ENABLED by default for public IPs. Private/localhost IPs are not masked.

rubocop:disable Metrics/ClassLength -- three parallel database configurations (geo, ASN, anonymizer) live here by design: each is a thin, symmetric writer/reader/loader trio, and splitting them into modules would hide the symmetry that makes them reviewable.

Examples:

Default configuration (privacy enabled)

config = Otto::Privacy::Config.new
config.enabled? # => true

Configure masking level

config = Otto::Privacy::Config.new
config.octet_precision = 2  # Mask 2 octets instead of 1

Constant Summary collapse

MAXMIND_DB_REQUIREMENT =
'~> 1.2'
PROFILES =

Named privacy profiles: validated presets over the individual knobs, so a deployment's observability posture is declared in one reviewable word instead of inferred from knob combinations.

  • :anonymous — mask every IP, including private/localhost. For deployments where even internal addresses are treated as PII.
  • :masked — the default posture: public IPs masked, private and localhost exempt (development-friendly privacy-by-default).
  • :audit — privacy disabled: real IPs flow to env and logs. For private/compliance environments where granular attributability supersedes IP privacy; retention responsibility transfers to the operator.

Note the axis this controls: what PERSISTS observably (env keys, logs, fingerprints). Precise ephemeral matching against the unmasked IP does not require :audit — see EnvKeys::IP_MATCH, available in every profile.

This table is the only list of knobs a profile governs: #profile= assigns whatever keys a preset carries, through each knob's writer so any check or normalization the writer does still runs. Every profile names the same keys, so applying one yields the same state whatever preceded it, and each key names a Config attribute with a writer and a public reader.

{
  anonymous: { disabled: false, mask_private_ips: true }.freeze,
     masked: { disabled: false, mask_private_ips: false }.freeze,
      audit: { disabled: true, mask_private_ips: false }.freeze,
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Core::Freezable

#deep_freeze!

Constructor Details

#initialize(options = {}) ⇒ Config

Initialize privacy configuration

Parameters:

  • options (Hash) (defaults to: {}) —

    Configuration options

Options Hash (options):

  • :octet_precision (Integer) —

    Number of trailing octets to mask (1 or 2, default: 1)

  • :hash_rotation_period (Integer) —

    Seconds between key rotation (default: 86400)

  • :geo_enabled (Boolean) —

    Enable geo-location resolution (default: true)

  • :geo_header (String) —

    Trusted, app-configured request header to read the country code from FIRST (before the built-in CDN provider headers). Accepts either the HTTP form ('X-Client-Country') or the Rack CGI form ('HTTP_X_CLIENT_COUNTRY'); both canonicalize to the 'HTTP_*' env key. Default nil (no app-configured header).

  • :geo_db_path (String) —

    Filesystem path to a MaxMind-format (.mmdb) country database used as the local IP->country fallback (looked up on the already MASKED IP). Requires the 'maxmind-db' gem. A bad/unreadable path raises at boot, not per-request. Default nil (no local database fallback).

  • :geo_db_reader (#get) —

    Bring-your-own MMDB reader (any object responding to #get, e.g. a MaxMind::DB or a compatible reader). Overrides :geo_db_path when set, so the reader choice stays independent of Otto. Default nil.

  • :asn_enabled (Boolean) —

    Enable ASN resolution (default: FALSE — unlike :geo_enabled, this signal is opt-in, so a deployment that never asks for it pays nothing and no database is opened)

  • :asn_db_path (String) —

    Filesystem path to a MaxMind-format (.mmdb) ASN database (looked up on the already MASKED IP, like the geo database). Requires the 'maxmind-db' gem. A bad/unreadable path raises at boot, not per-request. Default nil.

  • :asn_db_reader (#get) —

    Bring-your-own MMDB reader for ASN lookups (any object responding to #get). Overrides :asn_db_path when set. Default nil.

  • :anonymizer_enabled (Boolean) —

    Enable anonymizer (Tor/VPN/proxy/hosting) classification (default: FALSE — opt-in, same as :asn_enabled)

  • :anonymizer_db_path (String) —

    Filesystem path to a MaxMind-format (.mmdb) anonymous-IP database. Looked up on the UNMASKED IP — anonymizer data lists individual egress nodes at /32, so a masked lookup would answer for the node's neighbours; only the resulting label leaves the resolver. Requires the 'maxmind-db' gem. A bad path raises at boot. Default nil.

  • :anonymizer_db_reader (#get) —

    Bring-your-own MMDB reader for anonymizer lookups (any object responding to #get). Overrides :anonymizer_db_path. Default nil.

  • :disabled (Boolean) —

    Disable privacy entirely (default: false)

  • :mask_private_ips (Boolean) —

    Mask private/localhost IPs (default: false)

  • :correlation_secret (String) —

    A secret string that turns on IP correlation. Default nil, meaning off.

    It answers one question: "are these two requests, maybe months apart, from the same visitor?" — without your app ever seeing the real IP.

    Otto masks each IP before your app runs (203.0.113.42 becomes 203.0.113.0), which is too coarse to tell visitors apart. When a secret is set, Otto also fingerprints the full IP, before masking, and hands your app just the fingerprint as req.ip_correlation_hash. The same IP always produces the same fingerprint, and it can't be turned back into an IP without the secret.

    Keep the secret stable — changing it changes every fingerprint. An empty string is rejected, because an empty secret would let anyone reverse the fingerprint back to an IP.

  • :redis (Redis) —

    Optional Redis connection for multi-server environments

  • :profile (Symbol, String) —

    Named privacy profile (:anonymous, :masked, or :audit) applied as a preset; any other explicitly passed option overrides the preset. See PROFILES.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/otto/privacy/config.rb', line 140

def initialize(options = {})
  options = self.class.profile_presets(options[:profile]).merge(options) unless options[:profile].nil?
  @octet_precision = options.fetch(:octet_precision, 1)
  @hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
  @geo_enabled = options.fetch(:geo_enabled, true)
  @disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
  @mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
  self.correlation_secret = options.fetch(:correlation_secret, nil) # Opt-in stable IP-correlation secret
  @redis = options[:redis] # Optional Redis connection for multi-server environments

  # Geo-location fallback configuration (all opt-in, boot-time only).
  @geo_db_reader = nil # effective MMDB reader (built from path or injected)
  @geo_db_override = nil # reader injected via geo_db_reader= (wins over path)
  self.geo_header = options[:geo_header] # canonicalized to an HTTP_* env key (or nil)
  self.geo_db_reader = options[:geo_db_reader] if options.key?(:geo_db_reader)
  @geo_db_path = normalize_db_path(options[:geo_db_path])
  load_geo_database! # build/attach the reader now so a bad path fails at boot

  # ASN enrichment (opt-in, boot-time only). Same two-ivar shape as geo.
  @asn_enabled = options.fetch(:asn_enabled, false)
  @asn_db_reader = nil
  @asn_db_override = nil
  self.asn_db_reader = options[:asn_db_reader] if options.key?(:asn_db_reader)
  @asn_db_path = normalize_db_path(options[:asn_db_path])
  load_asn_database!

  # Anonymizer classification (opt-in, boot-time only).
  @anonymizer_enabled = options.fetch(:anonymizer_enabled, false)
  @anonymizer_db_reader = nil
  @anonymizer_db_override = nil
  self.anonymizer_db_reader = options[:anonymizer_db_reader] if options.key?(:anonymizer_db_reader)
  @anonymizer_db_path = normalize_db_path(options[:anonymizer_db_path])
  load_anonymizer_database!
end

Instance Attribute Details

#anonymizer_db_path ⇒ Object

Returns the value of attribute anonymizer_db_path.



68
69
70
# File 'lib/otto/privacy/config.rb', line 68

def anonymizer_db_path
  @anonymizer_db_path
end

#anonymizer_enabled ⇒ Object

Returns the value of attribute anonymizer_enabled.



66
67
68
# File 'lib/otto/privacy/config.rb', line 66

def anonymizer_enabled
  @anonymizer_enabled
end

#asn_db_path ⇒ Object

Returns the value of attribute asn_db_path.



68
69
70
# File 'lib/otto/privacy/config.rb', line 68

def asn_db_path
  @asn_db_path
end

#asn_enabled ⇒ Object

Returns the value of attribute asn_enabled.



66
67
68
# File 'lib/otto/privacy/config.rb', line 66

def asn_enabled
  @asn_enabled
end

#correlation_secret ⇒ Object

Returns the value of attribute correlation_secret.



68
69
70
# File 'lib/otto/privacy/config.rb', line 68

def correlation_secret
  @correlation_secret
end

#disabled ⇒ Object

Returns the value of attribute disabled.



68
69
70
# File 'lib/otto/privacy/config.rb', line 68

def disabled
  @disabled
end

#geo_db_path ⇒ Object

Returns the value of attribute geo_db_path.



68
69
70
# File 'lib/otto/privacy/config.rb', line 68

def geo_db_path
  @geo_db_path
end

#geo_enabled ⇒ Object

Returns the value of attribute geo_enabled.



66
67
68
# File 'lib/otto/privacy/config.rb', line 66

def geo_enabled
  @geo_enabled
end

#geo_header ⇒ Object

Returns the value of attribute geo_header.



68
69
70
# File 'lib/otto/privacy/config.rb', line 68

def geo_header
  @geo_header
end

#hash_rotation_period ⇒ Object

Returns the value of attribute hash_rotation_period.



66
67
68
# File 'lib/otto/privacy/config.rb', line 66

def hash_rotation_period
  @hash_rotation_period
end

#mask_private_ips ⇒ Object

Returns the value of attribute mask_private_ips.



66
67
68
# File 'lib/otto/privacy/config.rb', line 66

def mask_private_ips
  @mask_private_ips
end

#octet_precision ⇒ Object

Returns the value of attribute octet_precision.



66
67
68
# File 'lib/otto/privacy/config.rb', line 66

def octet_precision
  @octet_precision
end

Class Method Details

.canonicalize_geo_header(value) ⇒ String?

Canonicalize a geo header name to a Rack CGI env key ('HTTP_*').

Parameters:

  • value (String, nil) —

    header in HTTP ('X-Client-Country') or CGI form

Returns:

  • (String, nil) —

    'HTTP_*' env key, or nil for nil/blank input



438
439
440
441
442
443
444
445
446
# File 'lib/otto/privacy/config.rb', line 438

def self.canonicalize_geo_header(value)
  return nil if value.nil?

  key = value.to_s.strip
  return nil if key.empty?

  key = key.upcase.tr('-', '_')
  key.start_with?('HTTP_') ? key : "HTTP_#{key}"
end

.profile_presets(profile) ⇒ Hash

Look up the preset hash for a named profile, failing fast on typos.

Parameters:

  • profile (Symbol, String) —

    one of the PROFILES keys

Returns:

  • (Hash) —

    frozen preset hash

Raises:

  • (ArgumentError) —

    for an unknown profile name or an un-nameable type



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/otto/privacy/config.rb', line 388

def self.profile_presets(profile)
  # Neither Integer nor NilClass responds to #to_sym, so an unguarded
  # conversion raises NoMethodError for `profile: 123` or an explicit
  # `profile: nil` — an opaque failure inconsistent with the ArgumentError
  # the rest of this class raises for bad input (cf. correlation_secret=).
  unless profile.respond_to?(:to_sym)
    raise ArgumentError,
          "Privacy profile must be a Symbol or String, got: #{profile.class}"
  end

  PROFILES.fetch(profile.to_sym) do
    raise ArgumentError,
          "Unknown privacy profile: #{profile.inspect} (valid: #{PROFILES.keys.join(', ')})"
  end
end

.rotation_keys_store ⇒ Concurrent::Map

Get the class-level rotation keys store

Returns:

  • (Concurrent::Map) —

    Thread-safe map for rotation keys



78
79
80
81
# File 'lib/otto/privacy/config.rb', line 78

def rotation_keys_store
  @rotation_keys_store = Concurrent::Map.new unless defined?(@rotation_keys_store) && @rotation_keys_store
  @rotation_keys_store
end

Instance Method Details

#anonymizer_db_reader ⇒ #get?

The effective anonymizer reader, or nil when classification is off.

Returns:

  • (#get, nil)


324
325
326
# File 'lib/otto/privacy/config.rb', line 324

def anonymizer_db_reader
  @anonymizer_enabled ? @anonymizer_db_reader : nil
end

#anonymizer_db_reader=(reader) ⇒ Object

Inject a ready-made MMDB reader for anonymizer lookups. See #geo_db_reader=.

Parameters:

  • reader (#get, nil) —

    MMDB-compatible reader, or nil to clear

Raises:

  • (ArgumentError) —

    if reader does not respond to :get



313
314
315
316
317
318
319
# File 'lib/otto/privacy/config.rb', line 313

def anonymizer_db_reader=(reader)
  unless reader.nil? || reader.respond_to?(:get)
    raise ArgumentError, "anonymizer_db_reader must respond to :get, got: #{reader.class}"
  end

  @anonymizer_db_override = reader
end

#asn_db_reader ⇒ #get?

The effective ASN reader, or nil when ASN resolution is off.

Returns:

  • (#get, nil)


304
305
306
# File 'lib/otto/privacy/config.rb', line 304

def asn_db_reader
  @asn_enabled ? @asn_db_reader : nil
end

#asn_db_reader=(reader) ⇒ Object

Inject a ready-made MMDB reader for ASN lookups. See #geo_db_reader=.

Parameters:

  • reader (#get, nil) —

    MMDB-compatible reader, or nil to clear

Raises:

  • (ArgumentError) —

    if reader does not respond to :get



293
294
295
296
297
298
299
# File 'lib/otto/privacy/config.rb', line 293

def asn_db_reader=(reader)
  unless reader.nil? || reader.respond_to?(:get)
    raise ArgumentError, "asn_db_reader must respond to :get, got: #{reader.class}"
  end

  @asn_db_override = reader
end

#disable! ⇒ self

Disable privacy (allows access to original IPs)

IMPORTANT: This should only be used when you have a specific requirement to access original IP addresses. By default, Otto provides privacy-safe masked IPs.

Returns:

  • (self)


469
470
471
472
# File 'lib/otto/privacy/config.rb', line 469

def disable!
  @disabled = true
  self
end

#disabled? ⇒ Boolean

Check if privacy is disabled

Returns:

  • (Boolean) —

    true if privacy was explicitly disabled



458
459
460
# File 'lib/otto/privacy/config.rb', line 458

def disabled?
  @disabled
end

#enable! ⇒ self

Enable privacy (default state)

Returns:

  • (self)


477
478
479
480
# File 'lib/otto/privacy/config.rb', line 477

def enable!
  @disabled = false
  self
end

#enabled? ⇒ Boolean

Check if privacy is enabled

Returns:

  • (Boolean) —

    true if privacy is enabled (default)



451
452
453
# File 'lib/otto/privacy/config.rb', line 451

def enabled?
  !@disabled
end

#geo_db_reader ⇒ #get?

The effective MMDB reader for this config, or nil.

Returns nil when geo is disabled (so geo: false consults no database even if one was previously configured) or when neither a reader override nor a database path is set.

The reader is a plain instance variable — no class-level store. A MaxMind::DB reader computes its IPv4 start node eagerly at construction and performs no instance mutation on #get, so it is thread-safe under concurrency and unaffected by the shallow freeze deep_freeze! applies to it. That removes the only reason to hold it off-instance, and avoids an unbounded, never-evicted process-lifetime cache — important for a long-running server.

Returns:

  • (#get, nil) —

    the reader, or nil



285
286
287
# File 'lib/otto/privacy/config.rb', line 285

def geo_db_reader
  @geo_enabled ? @geo_db_reader : nil
end

#geo_db_reader=(reader) ⇒ Object

Inject a ready-made MMDB reader (any object responding to #get).

This keeps the reader choice independent of Otto (MaxMind::DB, yhirose's maxminddb, or a custom object all work) and is the seam used by tests. When set, it takes precedence over #geo_db_path. Passing nil clears the override. Takes effect on the next #load_geo_database!.

Parameters:

  • reader (#get, nil) —

    MMDB-compatible reader, or nil to clear

Raises:

  • (ArgumentError) —

    if reader does not respond to :get



262
263
264
265
266
267
268
# File 'lib/otto/privacy/config.rb', line 262

def geo_db_reader=(reader)
  unless reader.nil? || reader.respond_to?(:get)
    raise ArgumentError, "geo_db_reader must respond to :get, got: #{reader.class}"
  end

  @geo_db_override = reader
end

#load_anonymizer_database! ⇒ void

This method returns an undefined value.

Build/attach the anonymizer database reader. See #load_geo_database!.

Raises:

  • (ArgumentError) —

    if the path is unreadable or maxmind-db is absent



371
372
373
374
375
376
377
378
379
380
381
# File 'lib/otto/privacy/config.rb', line 371

def load_anonymizer_database!
  @anonymizer_db_reader = nil
  return unless @anonymizer_enabled

  @anonymizer_db_reader =
    if @anonymizer_db_override
      @anonymizer_db_override
    elsif @anonymizer_db_path
      build_maxmind_reader(@anonymizer_db_path, option_name: 'anonymizer_db_path')
    end
end

#load_asn_database! ⇒ void

This method returns an undefined value.

Build/attach the ASN database reader. See #load_geo_database! — same boot-time contract, same override-wins-over-path resolution.

Raises:

  • (ArgumentError) —

    if the path is unreadable or maxmind-db is absent



355
356
357
358
359
360
361
362
363
364
365
# File 'lib/otto/privacy/config.rb', line 355

def load_asn_database!
  @asn_db_reader = nil
  return unless @asn_enabled

  @asn_db_reader =
    if @asn_db_override
      @asn_db_override
    elsif @asn_db_path
      build_maxmind_reader(@asn_db_path, option_name: 'asn_db_path')
    end
end

#load_geo_database! ⇒ void

This method returns an undefined value.

Build/attach the geo database reader for the current configuration.

Boot-time only. Resolves the effective reader (injected override wins over a path). A String path is opened eagerly here so an unreadable path or a missing 'maxmind-db' gem raises now, at configuration time, rather than on the first request that needs a lookup. When geo is disabled, no database is loaded and no reader is retained.

Raises:

  • (ArgumentError) —

    if the path is unreadable or maxmind-db is absent



338
339
340
341
342
343
344
345
346
347
348
# File 'lib/otto/privacy/config.rb', line 338

def load_geo_database!
  @geo_db_reader = nil
  return unless @geo_enabled

  @geo_db_reader =
    if @geo_db_override
      @geo_db_override
    elsif @geo_db_path
      build_maxmind_reader(@geo_db_path)
    end
end

#profile ⇒ Symbol

The profile the current knob state corresponds to.

Derived from the live settings rather than remembering the last profile= call, so manual knob changes can never leave a stale label: what this returns is always what the config actually does.

Returns:

  • (Symbol) —

    :audit, :anonymous, or :masked



427
428
429
430
431
432
# File 'lib/otto/privacy/config.rb', line 427

def profile
  return :audit if @disabled
  return :anonymous if @mask_private_ips

  :masked
end

#profile=(profile) ⇒ Object

Apply a named privacy profile's presets to this config.

Assigns every knob the preset names (see PROFILES), so the result never depends on the profile applied before it: :anonymous -> :audit -> enable! lands on :masked, exactly as Config.new(profile: :audit).enable! does. Other settings (octet_precision, geo, correlation_secret, ...) are untouched.

Parameters:

  • profile (Symbol, String) —

    :anonymous, :masked, or :audit

Raises:

  • (ArgumentError) —

    for an unknown profile name



414
415
416
417
418
# File 'lib/otto/privacy/config.rb', line 414

def profile=(profile)
  self.class.profile_presets(profile).each do |knob, value|
    send(:"#{knob}=", value)
  end
end

#replace_enrichment_database_path!(prefix, value) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Replace one enrichment database path only after its new reader has opened successfully. Used by the boot-time configuration path; direct callers should normally use Otto#configure_ip_privacy.

Parameters:

  • prefix (:asn, :anonymizer) —

    database source to replace

  • value (String, nil) —

    new database path

Raises:

  • (ArgumentError) —

    if an enabled signal's path cannot be opened



243
244
245
246
247
248
249
250
251
# File 'lib/otto/privacy/config.rb', line 243

def replace_enrichment_database_path!(prefix, value)
  path = normalize_db_path(value)
  enabled = instance_variable_get(:"@#{prefix}_enabled")
  reader = path && enabled ? build_maxmind_reader(path, option_name: "#{prefix}_db_path") : nil

  instance_variable_set(:"@#{prefix}_db_path", path)
  instance_variable_set(:"@#{prefix}_db_override", nil)
  instance_variable_set(:"@#{prefix}_db_reader", reader)
end

#rotation_key ⇒ String

Get the current rotation key for IP hashing

Keys rotate at fixed intervals based on hash_rotation_period (default: 24 hours). Each rotation period gets a unique key, ensuring IP addresses hash differently across periods while remaining consistent within.

Multi-server support:

  • With Redis: Uses SET NX GET EX for atomic key generation across all servers
  • Without Redis: Falls back to in-memory Concurrent::Hash (single-server only)

Redis keys:

- rotation_key:{timestamp} - Stores the rotation key with TTL

Returns:

  • (String) —

    Current rotation key for hashing



496
497
498
499
500
501
502
# File 'lib/otto/privacy/config.rb', line 496

def rotation_key
  if @redis
    rotation_key_redis
  else
    rotation_key_memory
  end
end

#validate! ⇒ Object

Validate configuration settings

Raises:

  • (ArgumentError) —

    if configuration is invalid



507
508
509
510
511
512
513
514
515
516
517
518
# File 'lib/otto/privacy/config.rb', line 507

def validate!
  raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
                                                                                          2].include?(@octet_precision)

  # Type check before the numeric comparison: a non-Numeric value (false,
  # a String from unparsed config, ...) would otherwise surface as
  # NoMethodError/ArgumentError from #<, not a clear configuration error.
  return if @hash_rotation_period.is_a?(Numeric) && @hash_rotation_period >= 60

  raise ArgumentError,
        "hash_rotation_period must be at least 60 seconds, got: #{@hash_rotation_period.inspect}"
end