Module: Familia::Features::ExternalIdentifier

Defined in:
lib/familia/features/external_identifier.rb

Overview

Familia::Features::ExternalIdentifier

Defined Under Namespace

Modules: ModelClassMethods, ModelInstanceMethods Classes: ExternalIdentifierError, ExternalIdentifierFieldType

Instance Method Summary collapse

Instance Method Details

#derive_external_identifierString?

Derives a deterministic, public-facing external identifier from the object's internal objid.

This method uses the objid's high-quality randomness to seed a pseudorandom number generator (PRNG). The PRNG then acts as a complex, deterministic function to produce a new identifier that has no discernible mathematical correlation to the objid. This is a security measure to prevent leaking information (like timestamps from UUIDv7) from the internal identifier to the public one.

The resulting identifier is always deterministic: the same objid will always produce the same extid, which is crucial for lookups.

Returns:

  • (String, nil)

    A prefixed, base36-encoded external identifier, or nil if the objid is not present.

Raises:



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/familia/features/external_identifier.rb', line 294

def derive_external_identifier
  raise ExternalIdentifierError, 'Missing objid field' unless respond_to?(:objid)

  current_objid = objid
  return nil if current_objid.nil? || current_objid.to_s.empty?

  # Validate objid provenance for security guarantees
  validate_objid_provenance!

  # Normalize the objid to a consistent hex representation first.
  normalized_hex = normalize_objid_to_hex(current_objid)

  options = self.class.feature_options(:external_identifier)

  # Derive 16 deterministic bytes (128 bits) from the objid. We do not use
  # SecureRandom because the output must be deterministic (same objid ->
  # same extid, which lookups depend on).
  #
  # We deliberately avoid Random/Mersenne Twister here (see issue #310,
  # S3): MT is not a cryptographic PRNG -- its internal state can be
  # reconstructed from observed output -- and the previous implementation
  # seeded it via `seed.unpack1('Q>')`, discarding all but the first 64 of
  # the digest's 256 bits. A hash/HMAC keeps the derivation deterministic
  # while being one-way and preserving the full input entropy.
  #
  # When a `secret:` is configured for the feature, a keyed HMAC is used so
  # external IDs cannot be forged or inverted from a known objid. Without a
  # secret, a plain SHA-256 still removes the MT weakness and the 64-bit
  # truncation.
  secret = options[:secret]
  # Allow a callable secret (e.g. -> { ENV.fetch('EXTID_HMAC_SECRET') }) so
  # the value resolves lazily at first derivation instead of eagerly at
  # class-definition time. The model file then loads even when the env var is
  # absent (CI, tooling, introspection), and a missing secret still raises
  # loudly here -- where it matters -- rather than masking the whole model
  # behind a load-time error (#311).
  secret = secret.call if secret.respond_to?(:call)
  random_bytes =
    if secret && !secret.to_s.empty?
      OpenSSL::HMAC.digest('SHA256', secret.to_s, normalized_hex)[0, 16]
    else
      Digest::SHA256.digest(normalized_hex)[0, 16]
    end

  # Encode as a base36 string for a compact, URL-safe identifier.
  # 128 bits is approximately 25 characters in base36.
  external_part = random_bytes.unpack1('H*').to_i(16).to_s(36).rjust(25, '0')

  # Get format from feature options and interpolate the ID
  format = options[:format] || 'ext_%{id}'

  format % { id: external_part }
end

#destroy!Object



364
365
366
367
368
369
370
# File 'lib/familia/features/external_identifier.rb', line 364

def destroy!
  # Clean up extid mapping when object is destroyed
  current_extid = instance_variable_get(:@extid)
  self.class.extid_lookup.remove_field(current_extid) if current_extid

  super if defined?(super)
end

#external_identifierString

Full-length alias for extid for clarity when needed

Returns:

  • (String)

    The external identifier



352
353
354
# File 'lib/familia/features/external_identifier.rb', line 352

def external_identifier
  extid
end

#external_identifier=(value) ⇒ Object

Full-length alias setter for extid

Parameters:

  • value (String)

    The external identifier to set



360
361
362
# File 'lib/familia/features/external_identifier.rb', line 360

def external_identifier=(value)
  self.extid = value
end