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.

{
  anonymous: { disabled: false, mask_private_ips: true }.freeze,
     masked: { disabled: false, mask_private_ips: false }.freeze,
      audit: { disabled: true }.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.



133
134
135
136
137
138
139
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
# File 'lib/otto/privacy/config.rb', line 133

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_pathObject

Returns the value of attribute anonymizer_db_path.



61
62
63
# File 'lib/otto/privacy/config.rb', line 61

def anonymizer_db_path
  @anonymizer_db_path
end

#anonymizer_enabledObject

Returns the value of attribute anonymizer_enabled.



59
60
61
# File 'lib/otto/privacy/config.rb', line 59

def anonymizer_enabled
  @anonymizer_enabled
end

#asn_db_pathObject

Returns the value of attribute asn_db_path.



61
62
63
# File 'lib/otto/privacy/config.rb', line 61

def asn_db_path
  @asn_db_path
end

#asn_enabledObject

Returns the value of attribute asn_enabled.



59
60
61
# File 'lib/otto/privacy/config.rb', line 59

def asn_enabled
  @asn_enabled
end

#correlation_secretObject

Returns the value of attribute correlation_secret.



61
62
63
# File 'lib/otto/privacy/config.rb', line 61

def correlation_secret
  @correlation_secret
end

#disabledObject (readonly)

Returns the value of attribute disabled.



61
62
63
# File 'lib/otto/privacy/config.rb', line 61

def disabled
  @disabled
end

#geo_db_pathObject

Returns the value of attribute geo_db_path.



61
62
63
# File 'lib/otto/privacy/config.rb', line 61

def geo_db_path
  @geo_db_path
end

#geo_enabledObject

Returns the value of attribute geo_enabled.



59
60
61
# File 'lib/otto/privacy/config.rb', line 59

def geo_enabled
  @geo_enabled
end

#geo_headerObject

Returns the value of attribute geo_header.



61
62
63
# File 'lib/otto/privacy/config.rb', line 61

def geo_header
  @geo_header
end

#hash_rotation_periodObject

Returns the value of attribute hash_rotation_period.



59
60
61
# File 'lib/otto/privacy/config.rb', line 59

def hash_rotation_period
  @hash_rotation_period
end

#mask_private_ipsObject

Returns the value of attribute mask_private_ips.



59
60
61
# File 'lib/otto/privacy/config.rb', line 59

def mask_private_ips
  @mask_private_ips
end

#octet_precisionObject

Returns the value of attribute octet_precision.



59
60
61
# File 'lib/otto/privacy/config.rb', line 59

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



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

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



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'lib/otto/privacy/config.rb', line 381

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_storeConcurrent::Map

Get the class-level rotation keys store

Returns:

  • (Concurrent::Map)

    Thread-safe map for rotation keys



71
72
73
74
# File 'lib/otto/privacy/config.rb', line 71

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)


317
318
319
# File 'lib/otto/privacy/config.rb', line 317

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



306
307
308
309
310
311
312
# File 'lib/otto/privacy/config.rb', line 306

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)


297
298
299
# File 'lib/otto/privacy/config.rb', line 297

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



286
287
288
289
290
291
292
# File 'lib/otto/privacy/config.rb', line 286

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)


467
468
469
470
# File 'lib/otto/privacy/config.rb', line 467

def disable!
  @disabled = true
  self
end

#disabled?Boolean

Check if privacy is disabled

Returns:

  • (Boolean)

    true if privacy was explicitly disabled



456
457
458
# File 'lib/otto/privacy/config.rb', line 456

def disabled?
  @disabled
end

#enable!self

Enable privacy (default state)

Returns:

  • (self)


475
476
477
478
# File 'lib/otto/privacy/config.rb', line 475

def enable!
  @disabled = false
  self
end

#enabled?Boolean

Check if privacy is enabled

Returns:

  • (Boolean)

    true if privacy is enabled (default)



449
450
451
# File 'lib/otto/privacy/config.rb', line 449

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



278
279
280
# File 'lib/otto/privacy/config.rb', line 278

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



255
256
257
258
259
260
261
# File 'lib/otto/privacy/config.rb', line 255

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



364
365
366
367
368
369
370
371
372
373
374
# File 'lib/otto/privacy/config.rb', line 364

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



348
349
350
351
352
353
354
355
356
357
358
# File 'lib/otto/privacy/config.rb', line 348

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



331
332
333
334
335
336
337
338
339
340
341
# File 'lib/otto/privacy/config.rb', line 331

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

#profileSymbol

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



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

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.

Sets only the knobs the profile names (see PROFILES); other settings (octet_precision, geo, correlation_secret, ...) are untouched.

Presets are applied, not reset: a knob a profile does not name keeps its previous value. Switching :anonymous -> :audit therefore leaves mask_private_ips true, because :audit names only disabled. That is inert rather than wrong — disabled short-circuits privacy_enabled? before mask_private_ips is ever read, and #profile below tests @disabled first, so the derived label stays accurate. Switching on to :masked re-sets both knobs explicitly. Only surprising if you read the raw ivars.

Parameters:

  • profile (Symbol, String)

    :anonymous, :masked, or :audit

Raises:

  • (ArgumentError)

    for an unknown profile name



412
413
414
415
416
# File 'lib/otto/privacy/config.rb', line 412

def profile=(profile)
  presets = self.class.profile_presets(profile)
  @disabled = presets[:disabled] if presets.key?(:disabled)
  @mask_private_ips = presets[:mask_private_ips] if presets.key?(:mask_private_ips)
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



236
237
238
239
240
241
242
243
244
# File 'lib/otto/privacy/config.rb', line 236

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_keyString

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



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

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



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

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