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

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.



130
131
132
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
# File 'lib/otto/privacy/config.rb', line 130

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.



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

def anonymizer_db_path
  @anonymizer_db_path
end

#anonymizer_enabledObject

Returns the value of attribute anonymizer_enabled.



56
57
58
# File 'lib/otto/privacy/config.rb', line 56

def anonymizer_enabled
  @anonymizer_enabled
end

#asn_db_pathObject

Returns the value of attribute asn_db_path.



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

def asn_db_path
  @asn_db_path
end

#asn_enabledObject

Returns the value of attribute asn_enabled.



56
57
58
# File 'lib/otto/privacy/config.rb', line 56

def asn_enabled
  @asn_enabled
end

#correlation_secretObject

Returns the value of attribute correlation_secret.



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

def correlation_secret
  @correlation_secret
end

#disabledObject (readonly)

Returns the value of attribute disabled.



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

def disabled
  @disabled
end

#geo_db_pathObject

Returns the value of attribute geo_db_path.



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

def geo_db_path
  @geo_db_path
end

#geo_enabledObject

Returns the value of attribute geo_enabled.



56
57
58
# File 'lib/otto/privacy/config.rb', line 56

def geo_enabled
  @geo_enabled
end

#geo_headerObject

Returns the value of attribute geo_header.



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

def geo_header
  @geo_header
end

#hash_rotation_periodObject

Returns the value of attribute hash_rotation_period.



56
57
58
# File 'lib/otto/privacy/config.rb', line 56

def hash_rotation_period
  @hash_rotation_period
end

#mask_private_ipsObject

Returns the value of attribute mask_private_ips.



56
57
58
# File 'lib/otto/privacy/config.rb', line 56

def mask_private_ips
  @mask_private_ips
end

#octet_precisionObject

Returns the value of attribute octet_precision.



56
57
58
# File 'lib/otto/privacy/config.rb', line 56

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



433
434
435
436
437
438
439
440
441
# File 'lib/otto/privacy/config.rb', line 433

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



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

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



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

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)


314
315
316
# File 'lib/otto/privacy/config.rb', line 314

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



303
304
305
306
307
308
309
# File 'lib/otto/privacy/config.rb', line 303

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)


294
295
296
# File 'lib/otto/privacy/config.rb', line 294

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



283
284
285
286
287
288
289
# File 'lib/otto/privacy/config.rb', line 283

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)


464
465
466
467
# File 'lib/otto/privacy/config.rb', line 464

def disable!
  @disabled = true
  self
end

#disabled?Boolean

Check if privacy is disabled

Returns:

  • (Boolean)

    true if privacy was explicitly disabled



453
454
455
# File 'lib/otto/privacy/config.rb', line 453

def disabled?
  @disabled
end

#enable!self

Enable privacy (default state)

Returns:

  • (self)


472
473
474
475
# File 'lib/otto/privacy/config.rb', line 472

def enable!
  @disabled = false
  self
end

#enabled?Boolean

Check if privacy is enabled

Returns:

  • (Boolean)

    true if privacy is enabled (default)



446
447
448
# File 'lib/otto/privacy/config.rb', line 446

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



275
276
277
# File 'lib/otto/privacy/config.rb', line 275

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



252
253
254
255
256
257
258
# File 'lib/otto/privacy/config.rb', line 252

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



361
362
363
364
365
366
367
368
369
370
371
# File 'lib/otto/privacy/config.rb', line 361

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



345
346
347
348
349
350
351
352
353
354
355
# File 'lib/otto/privacy/config.rb', line 345

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



328
329
330
331
332
333
334
335
336
337
338
# File 'lib/otto/privacy/config.rb', line 328

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



422
423
424
425
426
427
# File 'lib/otto/privacy/config.rb', line 422

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



409
410
411
412
413
# File 'lib/otto/privacy/config.rb', line 409

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



233
234
235
236
237
238
239
240
241
# File 'lib/otto/privacy/config.rb', line 233

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



491
492
493
494
495
496
497
# File 'lib/otto/privacy/config.rb', line 491

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



502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/otto/privacy/config.rb', line 502

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