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.

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.

  • :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.



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/otto/privacy/config.rb', line 107

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_geo_db_path(options[:geo_db_path])
  load_geo_database! # build/attach the reader now so a bad path fails at boot
end

Instance Attribute Details

#correlation_secretObject

Returns the value of attribute correlation_secret.



53
54
55
# File 'lib/otto/privacy/config.rb', line 53

def correlation_secret
  @correlation_secret
end

#disabledObject (readonly)

Returns the value of attribute disabled.



53
54
55
# File 'lib/otto/privacy/config.rb', line 53

def disabled
  @disabled
end

#geo_db_pathObject

Returns the value of attribute geo_db_path.



53
54
55
# File 'lib/otto/privacy/config.rb', line 53

def geo_db_path
  @geo_db_path
end

#geo_enabledObject

Returns the value of attribute geo_enabled.



52
53
54
# File 'lib/otto/privacy/config.rb', line 52

def geo_enabled
  @geo_enabled
end

#geo_headerObject

Returns the value of attribute geo_header.



53
54
55
# File 'lib/otto/privacy/config.rb', line 53

def geo_header
  @geo_header
end

#hash_rotation_periodObject

Returns the value of attribute hash_rotation_period.



52
53
54
# File 'lib/otto/privacy/config.rb', line 52

def hash_rotation_period
  @hash_rotation_period
end

#mask_private_ipsObject

Returns the value of attribute mask_private_ips.



52
53
54
# File 'lib/otto/privacy/config.rb', line 52

def mask_private_ips
  @mask_private_ips
end

#octet_precisionObject

Returns the value of attribute octet_precision.



52
53
54
# File 'lib/otto/privacy/config.rb', line 52

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



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

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



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/otto/privacy/config.rb', line 232

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



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

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

#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)


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

def disable!
  @disabled = true
  self
end

#disabled?Boolean

Check if privacy is disabled

Returns:

  • (Boolean)

    true if privacy was explicitly disabled



307
308
309
# File 'lib/otto/privacy/config.rb', line 307

def disabled?
  @disabled
end

#enable!self

Enable privacy (default state)

Returns:

  • (self)


326
327
328
329
# File 'lib/otto/privacy/config.rb', line 326

def enable!
  @disabled = false
  self
end

#enabled?Boolean

Check if privacy is enabled

Returns:

  • (Boolean)

    true if privacy is enabled (default)



300
301
302
# File 'lib/otto/privacy/config.rb', line 300

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



201
202
203
# File 'lib/otto/privacy/config.rb', line 201

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



178
179
180
181
182
183
184
# File 'lib/otto/privacy/config.rb', line 178

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_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



215
216
217
218
219
220
221
222
223
224
225
# File 'lib/otto/privacy/config.rb', line 215

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



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

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



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

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

#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



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

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



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

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