familia 2.0.0.pre10 → 2.0.0.pre13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +2 -3
- data/CHANGELOG.rst +507 -0
- data/CLAUDE.md +5 -55
- data/Gemfile +1 -6
- data/Gemfile.lock +13 -7
- data/changelog.d/README.md +45 -34
- data/changelog.d/scriv.ini +5 -0
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
- data/docs/archive/FAMILIA_UPDATE.md +1 -1
- data/docs/archive/README.md +15 -19
- data/docs/guides/Feature-System-Autoloading.md +228 -0
- data/docs/guides/Home.md +1 -1
- data/docs/guides/Implementation-Guide.md +1 -1
- data/docs/guides/relationships-methods.md +1 -1
- data/docs/guides/time-utilities.md +221 -0
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +253 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre13.md +329 -0
- data/docs/migrating/v2.0.0-pre5.md +110 -0
- data/docs/migrating/v2.0.0-pre6.md +154 -0
- data/docs/migrating/v2.0.0-pre7.md +222 -0
- data/docs/overview.md +6 -7
- data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
- data/examples/autoloader/mega_customer/safe_dump_fields.rb +6 -0
- data/examples/autoloader/mega_customer.rb +17 -0
- data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
- data/examples/{relationships_basic.rb → relationships.rb} +2 -3
- data/examples/safe_dump.rb +281 -0
- data/familia.gemspec +5 -4
- data/lib/familia/autoloader.rb +53 -0
- data/lib/familia/base.rb +57 -0
- data/lib/familia/data_type.rb +4 -0
- data/lib/familia/encryption/encrypted_data.rb +4 -4
- data/lib/familia/encryption/manager.rb +6 -4
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +5 -0
- data/lib/familia/features/autoloadable.rb +113 -0
- data/lib/familia/features/encrypted_fields/concealed_string.rb +4 -2
- data/lib/familia/features/expiration.rb +4 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/quantization.rb +5 -0
- data/lib/familia/features/safe_dump.rb +74 -73
- data/lib/familia/features.rb +109 -17
- data/lib/familia/field_type.rb +2 -0
- data/lib/familia/horreum/core/serialization.rb +3 -3
- data/lib/familia/horreum/subclass/definition.rb +50 -7
- data/lib/familia/horreum.rb +2 -0
- data/lib/familia/json_serializer.rb +70 -0
- data/lib/familia/logging.rb +12 -10
- data/lib/familia/refinements/logger_trace.rb +57 -0
- data/lib/familia/refinements/snake_case.rb +40 -0
- data/lib/familia/refinements/time_utils.rb +248 -0
- data/lib/familia/refinements.rb +3 -49
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/utils.rb +2 -0
- data/lib/familia/validation/{test_helpers.rb → validation_helpers.rb} +2 -2
- data/lib/familia/validation.rb +1 -1
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +15 -2
- data/try/core/autoloader_try.rb +112 -0
- data/try/core/extensions_try.rb +38 -21
- data/try/core/familia_extended_try.rb +4 -3
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/time_utils_try.rb +130 -0
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/data_types/datatype_base_try.rb +3 -2
- data/try/features/autoloadable/autoloadable_try.rb +61 -0
- data/try/features/encrypted_fields/concealed_string_core_try.rb +8 -3
- data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +59 -17
- data/try/features/encrypted_fields/universal_serialization_safety_try.rb +36 -12
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +127 -0
- data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
- data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
- data/try/features/real_feature_integration_try.rb +8 -7
- data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +41 -17
- data/try/integration/cross_component_try.rb +3 -1
- metadata +61 -26
- data/CHANGELOG.md +0 -184
- data/changelog.d/fragments/.keep +0 -0
- data/changelog.d/template.md.j2 +0 -29
- data/lib/familia/core_ext.rb +0 -135
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
- data/lib/familia/features/external_identifiers.rb +0 -111
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
- data/lib/familia/features/object_identifiers.rb +0 -194
- data/setup.cfg +0 -12
data/lib/familia/encryption.rb
CHANGED
data/lib/familia/errors.rb
CHANGED
@@ -5,6 +5,11 @@ module Familia
|
|
5
5
|
class NoIdentifier < Problem; end
|
6
6
|
class NonUniqueKey < Problem; end
|
7
7
|
|
8
|
+
class FieldTypeError < Problem; end
|
9
|
+
class AutoloadError < Problem; end
|
10
|
+
|
11
|
+
class SerializerError < Problem; end
|
12
|
+
|
8
13
|
class HighRiskFactor < Problem
|
9
14
|
attr_reader :value
|
10
15
|
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../refinements/snake_case'
|
4
|
+
|
5
|
+
module Familia
|
6
|
+
module Features
|
7
|
+
# Enables automatic loading of feature-specific files when a feature is included in a user class.
|
8
|
+
#
|
9
|
+
# When included in a feature module, adds ClassMethods that detect when the feature is
|
10
|
+
# included in user classes, derives the feature name, and autoloads files matching
|
11
|
+
# conventional patterns in the user class's directory structure.
|
12
|
+
module Autoloadable
|
13
|
+
using Familia::Refinements::SnakeCase
|
14
|
+
|
15
|
+
# Sets up a feature module with autoloading capabilities.
|
16
|
+
#
|
17
|
+
# Extends the feature module with ClassMethods to handle post-inclusion autoloading.
|
18
|
+
#
|
19
|
+
# @param feature_module [Module] the feature module being enhanced
|
20
|
+
def self.included(feature_module)
|
21
|
+
feature_module.extend(ClassMethods)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Methods added to feature modules that include Autoloadable.
|
25
|
+
module ClassMethods
|
26
|
+
# Triggered when the feature is included in a user class.
|
27
|
+
#
|
28
|
+
# Sets up for post-inclusion autoloading. The actual autoloading
|
29
|
+
# is deferred until after feature setup completes.
|
30
|
+
#
|
31
|
+
# @param base [Class] the user class including this feature
|
32
|
+
def included(base)
|
33
|
+
# Call parent included method if it exists (defensive programming for mixed-in contexts)
|
34
|
+
super if defined?(super)
|
35
|
+
|
36
|
+
# No autoloading here - it's deferred to post_inclusion_autoload
|
37
|
+
# to ensure the feature is fully set up before extension files are loaded
|
38
|
+
end
|
39
|
+
|
40
|
+
# Called by the feature system after the feature is fully included.
|
41
|
+
#
|
42
|
+
# Uses const_source_location to determine where the base class is defined,
|
43
|
+
# then autoloads feature-specific extension files from that location.
|
44
|
+
#
|
45
|
+
# @param base [Class] the class that included this feature
|
46
|
+
# @param feature_name [Symbol] the name of the feature
|
47
|
+
# @param options [Hash] feature options (unused but kept for compatibility)
|
48
|
+
def post_inclusion_autoload(base, feature_name, options)
|
49
|
+
Familia.trace :FEATURE, nil, "[Autoloadable] post_inclusion_autoload called for #{feature_name} on #{base.name || base}", caller(1..1) if Familia.debug?
|
50
|
+
|
51
|
+
# Get the source location via Ruby's built-in introspection
|
52
|
+
source_location = nil
|
53
|
+
|
54
|
+
# Check for named classes that can be looked up via const_source_location
|
55
|
+
# Class#name always returns String or nil, so type check is redundant
|
56
|
+
if base.name && !base.name.empty?
|
57
|
+
begin
|
58
|
+
location_info = Module.const_source_location(base.name)
|
59
|
+
source_location = location_info&.first
|
60
|
+
Familia.trace :FEATURE, nil, "[Autoloadable] Source location for #{base.name}: #{source_location}", caller(1..1) if Familia.debug?
|
61
|
+
rescue NameError => e
|
62
|
+
# Handle cases where the class name is not a valid constant name
|
63
|
+
# This can happen in test environments with dynamically created classes
|
64
|
+
Familia.trace :FEATURE, nil, "[Autoloadable] Cannot resolve source location for #{base.name}: #{e.message}", caller(1..1) if Familia.debug?
|
65
|
+
end
|
66
|
+
else
|
67
|
+
Familia.trace :FEATURE, nil, "[Autoloadable] Skipping source location detection - base.name=#{base.name.inspect}", caller(1..1) if Familia.debug?
|
68
|
+
end
|
69
|
+
|
70
|
+
# Autoload feature-specific files if we have a valid source location
|
71
|
+
if source_location && !source_location.include?('-e') # Skip eval/irb contexts
|
72
|
+
Familia.trace :FEATURE, nil, "[Autoloadable] Calling autoload_feature_files with #{source_location}", caller(1..1) if Familia.debug?
|
73
|
+
autoload_feature_files(source_location, base, feature_name.to_s.snake_case)
|
74
|
+
else
|
75
|
+
Familia.trace :FEATURE, nil, "[Autoloadable] Skipping autoload - no valid source location", caller(1..1) if Familia.debug?
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# Autoloads feature-specific files from conventional directory patterns.
|
82
|
+
#
|
83
|
+
# Searches for files matching patterns like:
|
84
|
+
# - model_name/feature_name_*.rb
|
85
|
+
# - model_name/features/feature_name_*.rb
|
86
|
+
# - features/feature_name_*.rb
|
87
|
+
#
|
88
|
+
# @param location_path [String] path where the user class is defined
|
89
|
+
# @param base [Class] the user class including the feature
|
90
|
+
# @param feature_name [String] snake_case name of the feature
|
91
|
+
def autoload_feature_files(location_path, base, feature_name)
|
92
|
+
base_dir = File.dirname(location_path)
|
93
|
+
|
94
|
+
# Handle anonymous classes gracefully
|
95
|
+
model_name = base.name ? base.name.snake_case : "anonymous_#{base.object_id}"
|
96
|
+
|
97
|
+
# Look for feature-specific files in conventional locations
|
98
|
+
patterns = [
|
99
|
+
File.join(base_dir, model_name, "#{feature_name}_*.rb"),
|
100
|
+
File.join(base_dir, model_name, 'features', "#{feature_name}_*.rb"),
|
101
|
+
File.join(base_dir, 'features', "#{feature_name}_*.rb"),
|
102
|
+
]
|
103
|
+
|
104
|
+
# Use Autoloader's shared method for consistent file loading
|
105
|
+
Familia::Autoloader.autoload_files(
|
106
|
+
patterns,
|
107
|
+
log_prefix: "Autoloadable(#{feature_name})"
|
108
|
+
)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -37,6 +37,8 @@
|
|
37
37
|
# user.to_json # Safe - contains [CONCEALED]
|
38
38
|
#
|
39
39
|
class ConcealedString
|
40
|
+
REDACTED = '[CONCEALED]'.freeze
|
41
|
+
|
40
42
|
# Create a concealed string wrapper
|
41
43
|
#
|
42
44
|
# @param encrypted_data [String] The encrypted JSON data
|
@@ -264,9 +266,9 @@ class ConcealedString
|
|
264
266
|
{ concealed: true }
|
265
267
|
end
|
266
268
|
|
267
|
-
# Prevent exposure in JSON serialization
|
269
|
+
# Prevent exposure in JSON serialization - fail closed for security
|
268
270
|
def to_json(*)
|
269
|
-
|
271
|
+
raise Familia::SerializerError, "ConcealedString cannot be serialized to JSON"
|
270
272
|
end
|
271
273
|
|
272
274
|
# Prevent exposure in Rails serialization (as_json -> to_json)
|
@@ -149,6 +149,8 @@ module Familia
|
|
149
149
|
module Expiration
|
150
150
|
@default_expiration = nil
|
151
151
|
|
152
|
+
using Familia::Refinements::TimeUtils
|
153
|
+
|
152
154
|
def self.included(base)
|
153
155
|
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
154
156
|
base.extend ClassMethods
|
@@ -160,6 +162,8 @@ module Familia
|
|
160
162
|
base.instance_variable_set(:@default_expiration, @default_expiration)
|
161
163
|
end
|
162
164
|
|
165
|
+
# Familia::Expiration::ClassMethods
|
166
|
+
#
|
163
167
|
module ClassMethods
|
164
168
|
# Set the default expiration time for instances of this class
|
165
169
|
#
|
@@ -0,0 +1,310 @@
|
|
1
|
+
# lib/familia/features/external_identifier.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
# Familia::Features::ExternalIdentifier
|
6
|
+
#
|
7
|
+
module ExternalIdentifier
|
8
|
+
def self.included(base)
|
9
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
10
|
+
base.extend ClassMethods
|
11
|
+
|
12
|
+
# Ensure default prefix is set in feature options
|
13
|
+
base.add_feature_options(:external_identifier, prefix: 'ext')
|
14
|
+
|
15
|
+
# Add class-level mapping for extid -> id lookups
|
16
|
+
base.class_hashkey :extid_lookup
|
17
|
+
|
18
|
+
# Register the extid field using our custom field type
|
19
|
+
base.register_field_type(ExternalIdentifierFieldType.new(:extid, as: :extid, fast_method: false))
|
20
|
+
end
|
21
|
+
|
22
|
+
# Error classes
|
23
|
+
class ExternalIdentifierError < FieldTypeError; end
|
24
|
+
|
25
|
+
# ExternalIdentifierFieldType - Fields that derive deterministic external identifiers
|
26
|
+
#
|
27
|
+
# External identifier fields derive shorter, public-facing identifiers that are
|
28
|
+
# deterministically derived from object identifiers. These IDs are safe for use
|
29
|
+
# in URLs, APIs, and other external contexts where shorter IDs are preferred.
|
30
|
+
#
|
31
|
+
# Key characteristics:
|
32
|
+
# - Deterministic generation from objid ensures consistency
|
33
|
+
# - Shorter than objid (128-bit vs 256-bit) for external use
|
34
|
+
# - Base-36 encoding for URL-safe identifiers
|
35
|
+
# - 'ext_' prefix for clear identification as external IDs
|
36
|
+
# - Lazy generation preserves values from initialization
|
37
|
+
#
|
38
|
+
# @example Using external identifier fields
|
39
|
+
# class User < Familia::Horreum
|
40
|
+
# feature :object_identifier
|
41
|
+
# feature :external_identifier
|
42
|
+
# field :email
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# user = User.new(email: 'user@example.com')
|
46
|
+
# user.objid # => "01234567-89ab-7def-8000-123456789abc"
|
47
|
+
# user.extid # => "ext_abc123def456ghi789" (deterministic from objid)
|
48
|
+
#
|
49
|
+
# # Same objid always produces same extid
|
50
|
+
# user2 = User.new(objid: user.objid, email: 'user@example.com')
|
51
|
+
# user2.extid # => "ext_abc123def456ghi789" (identical to user.extid)
|
52
|
+
#
|
53
|
+
class ExternalIdentifierFieldType < Familia::FieldType
|
54
|
+
# Override getter to provide lazy generation from objid
|
55
|
+
#
|
56
|
+
# Derives the external identifier deterministically from the object's
|
57
|
+
# objid. This ensures consistency - the same objid will always produce
|
58
|
+
# the same extid. Only derives when objid is available.
|
59
|
+
#
|
60
|
+
# @param klass [Class] The class to define the method on
|
61
|
+
#
|
62
|
+
def define_getter(klass)
|
63
|
+
field_name = @name
|
64
|
+
method_name = @method_name
|
65
|
+
|
66
|
+
handle_method_conflict(klass, method_name) do
|
67
|
+
klass.define_method method_name do
|
68
|
+
# Check if we already have a value (from initialization or previous generation)
|
69
|
+
existing_value = instance_variable_get(:"@#{field_name}")
|
70
|
+
return existing_value unless existing_value.nil?
|
71
|
+
|
72
|
+
# Derive external identifier from objid if available
|
73
|
+
derived_extid = derive_external_identifier
|
74
|
+
return unless derived_extid
|
75
|
+
|
76
|
+
instance_variable_set(:"@#{field_name}", derived_extid)
|
77
|
+
|
78
|
+
# Update mapping if we have an identifier
|
79
|
+
self.class.extid_lookup[derived_extid] = identifier if respond_to?(:identifier) && identifier
|
80
|
+
|
81
|
+
derived_extid
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Override setter to preserve values during initialization
|
87
|
+
#
|
88
|
+
# This ensures that values passed during object initialization
|
89
|
+
# (e.g., when loading from Redis) are preserved and not overwritten
|
90
|
+
# by the lazy generation logic.
|
91
|
+
#
|
92
|
+
# @param klass [Class] The class to define the method on
|
93
|
+
#
|
94
|
+
def define_setter(klass)
|
95
|
+
field_name = @name
|
96
|
+
method_name = @method_name
|
97
|
+
|
98
|
+
handle_method_conflict(klass, :"#{method_name}=") do
|
99
|
+
klass.define_method :"#{method_name}=" do |value|
|
100
|
+
# Remove old mapping if extid is changing
|
101
|
+
old_value = instance_variable_get(:"@#{field_name}")
|
102
|
+
self.class.extid_lookup.del(old_value) if old_value && old_value != value && respond_to?(:identifier)
|
103
|
+
|
104
|
+
# Set the new value
|
105
|
+
instance_variable_set(:"@#{field_name}", value)
|
106
|
+
|
107
|
+
# Update mapping if we have both extid and identifier
|
108
|
+
return unless value && respond_to?(:identifier) && identifier
|
109
|
+
|
110
|
+
self.class.extid_lookup[value] = identifier
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# External identifier fields are persisted to database
|
116
|
+
#
|
117
|
+
# @return [Boolean] true - external identifiers are always persisted
|
118
|
+
#
|
119
|
+
def persistent?
|
120
|
+
true
|
121
|
+
end
|
122
|
+
|
123
|
+
# Category for external identifier fields
|
124
|
+
#
|
125
|
+
# @return [Symbol] :external_identifier
|
126
|
+
#
|
127
|
+
def category
|
128
|
+
:external_identifier
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# ExternalIdentifier::ClassMethods
|
133
|
+
#
|
134
|
+
module ClassMethods
|
135
|
+
# Find an object by its external identifier
|
136
|
+
#
|
137
|
+
# @param extid [String] The external identifier to search for
|
138
|
+
# @return [Object, nil] The object if found, nil otherwise
|
139
|
+
#
|
140
|
+
def find_by_extid(extid)
|
141
|
+
return nil if extid.to_s.empty?
|
142
|
+
|
143
|
+
if Familia.debug?
|
144
|
+
reference = caller(1..1).first
|
145
|
+
Familia.trace :FIND_BY_EXTID, Familia.dbclient, extid, reference
|
146
|
+
end
|
147
|
+
|
148
|
+
# Look up the primary ID from the external ID mapping
|
149
|
+
primary_id = extid_lookup[extid]
|
150
|
+
return nil if primary_id.nil?
|
151
|
+
|
152
|
+
# Find the object by its primary ID
|
153
|
+
find_by_id(primary_id)
|
154
|
+
rescue Familia::NotFound
|
155
|
+
# If the object was deleted but mapping wasn't cleaned up
|
156
|
+
extid_lookup.del(extid)
|
157
|
+
nil
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Derives a deterministic, public-facing external identifier from the object's
|
162
|
+
# internal `objid`.
|
163
|
+
#
|
164
|
+
# This method uses the `objid`'s high-quality randomness to seed a
|
165
|
+
# pseudorandom number generator (PRNG). The PRNG then acts as a complex,
|
166
|
+
# deterministic function to produce a new identifier that has no discernible
|
167
|
+
# mathematical correlation to the `objid`. This is a security measure to
|
168
|
+
# prevent leaking information (like timestamps from UUIDv7) from the internal
|
169
|
+
# identifier to the public one.
|
170
|
+
#
|
171
|
+
# The resulting identifier is always deterministic: the same `objid` will
|
172
|
+
# always produce the same `extid`, which is crucial for lookups.
|
173
|
+
#
|
174
|
+
# @return [String, nil] A prefixed, base36-encoded external identifier, or nil
|
175
|
+
# if the `objid` is not present.
|
176
|
+
# @raise [ExternalIdentifierError] if the `objid` provenance is unknown.
|
177
|
+
def derive_external_identifier
|
178
|
+
raise ExternalIdentifierError, 'Missing objid field' unless respond_to?(:objid)
|
179
|
+
|
180
|
+
current_objid = objid
|
181
|
+
return nil if current_objid.nil? || current_objid.to_s.empty?
|
182
|
+
|
183
|
+
# Validate objid provenance for security guarantees
|
184
|
+
validate_objid_provenance!
|
185
|
+
|
186
|
+
# Normalize the objid to a consistent hex representation first.
|
187
|
+
normalized_hex = normalize_objid_to_hex(current_objid)
|
188
|
+
|
189
|
+
# Use the objid's randomness to create a deterministic, yet secure,
|
190
|
+
# external identifier. We do not use SecureRandom here because the output
|
191
|
+
# must be deterministic.
|
192
|
+
#
|
193
|
+
# The process is as follows:
|
194
|
+
# 1. The objid (a high-entropy value) is hashed to create a uniform seed.
|
195
|
+
# 2. The seed initializes a standard PRNG (Random.new).
|
196
|
+
# 3. The PRNG acts as a deterministic function to generate a sequence of
|
197
|
+
# bytes that appears random, obscuring the original objid.
|
198
|
+
|
199
|
+
# 1. Create a high-quality, uniform seed from the objid's entropy.
|
200
|
+
seed = Digest::SHA256.digest(normalized_hex)
|
201
|
+
|
202
|
+
# 2. Initialize a PRNG with the seed. The same seed will always produce
|
203
|
+
# the same sequence of "random" numbers.
|
204
|
+
prng = Random.new(seed.unpack1('Q>'))
|
205
|
+
|
206
|
+
# 3. Generate 16 bytes (128 bits) of deterministic output.
|
207
|
+
random_bytes = prng.bytes(16)
|
208
|
+
|
209
|
+
# Encode as a base36 string for a compact, URL-safe identifier.
|
210
|
+
# 128 bits is approximately 25 characters in base36.
|
211
|
+
external_part = random_bytes.unpack1('H*').to_i(16).to_s(36).rjust(25, '0')
|
212
|
+
|
213
|
+
# Get prefix from feature options, default to "ext"
|
214
|
+
options = self.class.feature_options(:external_identifier)
|
215
|
+
prefix = options[:prefix] || 'ext'
|
216
|
+
|
217
|
+
"#{prefix}_#{external_part}"
|
218
|
+
end
|
219
|
+
|
220
|
+
# Full-length alias for extid for clarity when needed
|
221
|
+
#
|
222
|
+
# @return [String] The external identifier
|
223
|
+
#
|
224
|
+
def external_identifier
|
225
|
+
extid
|
226
|
+
end
|
227
|
+
|
228
|
+
# Full-length alias setter for extid
|
229
|
+
#
|
230
|
+
# @param value [String] The external identifier to set
|
231
|
+
#
|
232
|
+
def external_identifier=(value)
|
233
|
+
self.extid = value
|
234
|
+
end
|
235
|
+
|
236
|
+
def destroy!
|
237
|
+
# Clean up extid mapping when object is destroyed
|
238
|
+
current_extid = instance_variable_get(:@extid)
|
239
|
+
self.class.extid_lookup.del(current_extid) if current_extid
|
240
|
+
|
241
|
+
super if defined?(super)
|
242
|
+
end
|
243
|
+
|
244
|
+
private
|
245
|
+
|
246
|
+
# Validate that objid comes from a known secure ObjectIdentifier generator
|
247
|
+
#
|
248
|
+
# This ensures we only derive external identifiers from objid values that
|
249
|
+
# have known provenance and security properties. External identifiers derived
|
250
|
+
# from objid values of unknown origin cannot provide security guarantees.
|
251
|
+
#
|
252
|
+
# @raise [ExternalIdentifierError] if objid has unknown provenance
|
253
|
+
#
|
254
|
+
def validate_objid_provenance!
|
255
|
+
# Check if we have provenance information about the objid generator
|
256
|
+
generator_used = objid_generator_used
|
257
|
+
|
258
|
+
if generator_used.nil?
|
259
|
+
error_msg = <<~MSG.strip
|
260
|
+
Cannot derive external identifier: objid provenance unknown.
|
261
|
+
External identifiers can only be derived from objid values created
|
262
|
+
by the ObjectIdentifier feature to ensure security guarantees.
|
263
|
+
MSG
|
264
|
+
raise ExternalIdentifierError, error_msg
|
265
|
+
end
|
266
|
+
|
267
|
+
# Additional validation: ensure the ObjectIdentifier feature is active
|
268
|
+
return if self.class.features_enabled.include?(:object_identifier)
|
269
|
+
|
270
|
+
raise ExternalIdentifierError,
|
271
|
+
'ExternalIdentifier requires ObjectIdentifier feature for secure provenance.'
|
272
|
+
end
|
273
|
+
|
274
|
+
# Normalize objid to hex format based on the known generator type
|
275
|
+
#
|
276
|
+
# Since we track which generator was used, we can safely normalize the objid
|
277
|
+
# to hex format without relying on string pattern matching. This eliminates
|
278
|
+
# the ambiguity between uuid7, uuid4, and hex formats.
|
279
|
+
#
|
280
|
+
# @param objid_value [String] The objid to normalize
|
281
|
+
# @return [String] Hex string suitable for SecureIdentifier processing
|
282
|
+
#
|
283
|
+
def normalize_objid_to_hex(objid_value)
|
284
|
+
generator_used = objid_generator_used
|
285
|
+
|
286
|
+
case generator_used
|
287
|
+
when :uuid_v7, :uuid_v4
|
288
|
+
# UUID formats: remove hyphens to get 128-bit hex string
|
289
|
+
objid_value.delete('-')
|
290
|
+
when :hex
|
291
|
+
# Already in hex format (256-bit)
|
292
|
+
objid_value
|
293
|
+
else
|
294
|
+
# Custom generator: attempt to normalize, but we can't guarantee format
|
295
|
+
normalized = objid_value.to_s.delete('-')
|
296
|
+
unless normalized.match?(/\A[0-9a-fA-F]+\z/)
|
297
|
+
error_msg = <<~MSG.strip
|
298
|
+
Cannot normalize objid from custom generator #{generator_used}:
|
299
|
+
value must be in hexadecimal format, got: #{objid_value}
|
300
|
+
MSG
|
301
|
+
raise ExternalIdentifierError, error_msg
|
302
|
+
end
|
303
|
+
normalized
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
Familia::Base.add_feature self, :external_identifier, depends_on: [:object_identifier]
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|