familia 2.0.0.pre4 → 2.0.0.pre6
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/.gitignore +3 -0
- data/.rubocop_todo.yml +17 -17
- data/CLAUDE.md +11 -8
- data/Gemfile +5 -1
- data/Gemfile.lock +19 -3
- data/README.md +36 -157
- data/docs/overview.md +359 -0
- data/docs/wiki/API-Reference.md +347 -0
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +600 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +106 -0
- data/docs/wiki/Implementation-Guide.md +276 -0
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +183 -0
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/lib/familia/base.rb +18 -27
- data/lib/familia/connection.rb +6 -5
- data/lib/familia/{datatype → data_type}/commands.rb +2 -5
- data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
- data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
- data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
- data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
- data/lib/familia/{datatype.rb → data_type.rb} +12 -14
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +119 -0
- data/lib/familia/encryption/provider.rb +49 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -0
- data/lib/familia/encryption/registry.rb +50 -0
- data/lib/familia/encryption.rb +178 -0
- data/lib/familia/encryption_request_cache.rb +68 -0
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -0
- data/lib/familia/features/encrypted_fields.rb +28 -0
- data/lib/familia/features/expiration.rb +107 -77
- data/lib/familia/features/quantization.rb +5 -9
- data/lib/familia/features/relatable_objects.rb +2 -4
- data/lib/familia/features/safe_dump.rb +14 -17
- data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
- data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
- data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
- data/lib/familia/features/transient_fields.rb +47 -0
- data/lib/familia/features.rb +40 -24
- data/lib/familia/field_type.rb +273 -0
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
- data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
- data/lib/familia/horreum/subclass/definition.rb +469 -0
- data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +30 -22
- data/lib/familia/logging.rb +14 -14
- data/lib/familia/settings.rb +39 -3
- data/lib/familia/utils.rb +45 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +3 -2
- data/try/core/base_enhancements_try.rb +115 -0
- data/try/core/connection_try.rb +0 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -5
- data/try/core/familia_extended_try.rb +3 -4
- data/try/core/familia_try.rb +1 -2
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/core/pools_try.rb +2 -2
- data/try/core/secure_identifier_try.rb +0 -1
- data/try/core/settings_try.rb +0 -1
- data/try/core/utils_try.rb +0 -1
- data/try/{datatypes → data_types}/boolean_try.rb +1 -2
- data/try/data_types/counter_try.rb +93 -0
- data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
- data/try/{datatypes → data_types}/hash_try.rb +1 -2
- data/try/{datatypes → data_types}/list_try.rb +1 -2
- data/try/data_types/lock_try.rb +133 -0
- data/try/{datatypes → data_types}/set_try.rb +1 -2
- data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
- data/try/{datatypes → data_types}/string_try.rb +1 -2
- data/try/debugging/README.md +32 -0
- data/try/debugging/cache_behavior_tracer.rb +91 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/debugging/encryption_method_tracer.rb +138 -0
- data/try/debugging/provider_diagnostics.rb +110 -0
- data/try/edge_cases/hash_symbolization_try.rb +0 -1
- data/try/edge_cases/json_serialization_try.rb +0 -1
- data/try/edge_cases/reserved_keywords_try.rb +42 -11
- data/try/encryption/config_persistence_try.rb +192 -0
- data/try/encryption/encryption_core_try.rb +328 -0
- data/try/encryption/instance_variable_scope_try.rb +31 -0
- data/try/encryption/module_loading_try.rb +28 -0
- data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
- data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
- data/try/encryption/roundtrip_validation_try.rb +28 -0
- data/try/encryption/secure_memory_handling_try.rb +125 -0
- data/try/features/encrypted_fields_core_try.rb +125 -0
- data/try/features/encrypted_fields_integration_try.rb +216 -0
- data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
- data/try/features/encrypted_fields_security_try.rb +377 -0
- data/try/features/encryption_fields/aad_protection_try.rb +138 -0
- data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +141 -0
- data/try/features/encryption_fields/error_conditions_try.rb +116 -0
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
- data/try/features/encryption_fields/fresh_key_try.rb +168 -0
- data/try/features/encryption_fields/key_rotation_try.rb +123 -0
- data/try/features/encryption_fields/memory_security_try.rb +37 -0
- data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +56 -0
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +199 -0
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/expiration_try.rb +0 -1
- data/try/features/feature_dependencies_try.rb +159 -0
- data/try/features/quantization_try.rb +0 -1
- data/try/features/real_feature_integration_try.rb +148 -0
- data/try/features/relatable_objects_try.rb +0 -1
- data/try/features/safe_dump_advanced_try.rb +0 -1
- data/try/features/safe_dump_try.rb +0 -1
- data/try/features/transient_fields/redacted_string_try.rb +248 -0
- data/try/features/transient_fields/refresh_reset_try.rb +164 -0
- data/try/features/transient_fields/simple_refresh_test.rb +50 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
- data/try/features/transient_fields_core_try.rb +181 -0
- data/try/features/transient_fields_integration_try.rb +260 -0
- data/try/helpers/test_helpers.rb +67 -0
- data/try/horreum/base_try.rb +157 -3
- data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
- data/try/horreum/field_categories_try.rb +118 -0
- data/try/horreum/field_definition_try.rb +96 -0
- data/try/horreum/initialization_try.rb +1 -2
- data/try/horreum/relations_try.rb +1 -2
- data/try/horreum/serialization_persistent_fields_try.rb +165 -0
- data/try/horreum/serialization_try.rb +41 -7
- data/try/memory/memory_basic_test.rb +73 -0
- data/try/memory/memory_detailed_test.rb +121 -0
- data/try/memory/memory_docker_ruby_dump.sh +80 -0
- data/try/memory/memory_search_for_string.rb +83 -0
- data/try/memory/test_actual_redactedstring_protection.rb +38 -0
- data/try/models/customer_safe_dump_try.rb +1 -2
- data/try/models/customer_try.rb +1 -2
- data/try/models/datatype_base_try.rb +1 -2
- data/try/models/familia_object_try.rb +0 -1
- metadata +131 -23
- data/lib/familia/horreum/serialization.rb +0 -445
@@ -1,100 +1,130 @@
|
|
1
1
|
# lib/familia/features/expiration.rb
|
2
2
|
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
# Famnilia::Features::Expiration
|
6
|
+
#
|
7
|
+
module Expiration
|
8
|
+
@default_expiration = nil
|
3
9
|
|
4
|
-
|
10
|
+
def self.included(base)
|
11
|
+
Familia.trace :LOADED!, nil, self, caller(1..1) if Familia.debug?
|
12
|
+
base.extend ClassMethods
|
5
13
|
|
6
|
-
|
7
|
-
|
14
|
+
# Optionally define default_expiration in the class to make
|
15
|
+
# sure we always have an array to work with.
|
16
|
+
return if base.instance_variable_defined?(:@default_expiration)
|
8
17
|
|
9
|
-
|
18
|
+
base.instance_variable_set(:@default_expiration, @default_expiration) # set above
|
19
|
+
end
|
10
20
|
|
11
|
-
|
21
|
+
# ClassMethods
|
22
|
+
#
|
23
|
+
module ClassMethods
|
24
|
+
attr_writer :default_expiration
|
12
25
|
|
13
|
-
|
14
|
-
|
15
|
-
|
26
|
+
def default_expiration(num = nil)
|
27
|
+
@default_expiration = num.to_f unless num.nil?
|
28
|
+
@default_expiration || parent&.default_expiration || Familia.default_expiration
|
29
|
+
end
|
16
30
|
end
|
17
31
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
Familia.ld "[#{base}] Loaded #{self}"
|
22
|
-
base.extend ClassMethods
|
32
|
+
def default_expiration=(num)
|
33
|
+
@default_expiration = num.to_f
|
34
|
+
end
|
23
35
|
|
24
|
-
|
25
|
-
|
26
|
-
unless base.instance_variable_defined?(:@default_expiration)
|
27
|
-
base.instance_variable_set(:@default_expiration, @default_expiration) # set above
|
36
|
+
def default_expiration
|
37
|
+
@default_expiration || self.class.default_expiration
|
28
38
|
end
|
29
|
-
end
|
30
39
|
|
31
|
-
|
32
|
-
|
33
|
-
|
40
|
+
# Sets an expiration time for the Database data associated with this object.
|
41
|
+
#
|
42
|
+
# This method allows setting a Time To Live (TTL) for the data in Redis,
|
43
|
+
# after which it will be automatically removed.
|
44
|
+
#
|
45
|
+
# @param default_expiration [Integer, nil] The Time To Live in seconds. If nil, the default
|
46
|
+
# TTL will be used.
|
47
|
+
#
|
48
|
+
# @return [Boolean] Returns true if the expiration was set successfully,
|
49
|
+
# false otherwise.
|
50
|
+
#
|
51
|
+
# @example Setting an expiration of one day
|
52
|
+
# object.update_expiration(default_expiration: 86400)
|
53
|
+
#
|
54
|
+
# @note If Default expiration is set to zero, the expiration will be removed, making the
|
55
|
+
# data persist indefinitely.
|
56
|
+
#
|
57
|
+
# @raise [Familia::Problem] Raises an error if the default expiration is not a non-negative
|
58
|
+
# integer.
|
59
|
+
#
|
60
|
+
def update_expiration(default_expiration: nil)
|
61
|
+
default_expiration ||= self.default_expiration
|
62
|
+
|
63
|
+
if self.class.has_relations?
|
64
|
+
Familia.ld "[update_expiration] #{self.class} has relations: #{self.class.related_fields.keys}"
|
65
|
+
self.class.related_fields.each do |name, definition|
|
66
|
+
next if definition.opts[:default_expiration].nil?
|
67
|
+
|
68
|
+
obj = send(name)
|
69
|
+
Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
|
70
|
+
obj.update_expiration(default_expiration: default_expiration)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# It's important to raise exceptions here and not just log warnings. We
|
75
|
+
# don't want to silently fail at setting expirations and cause data
|
76
|
+
# retention issues (e.g. not removed in a timely fashion).
|
77
|
+
#
|
78
|
+
# For the same reason, we don't want to default to 0 bc there's not a
|
79
|
+
# good reason for the default_expiration to not be set in the first place. If the
|
80
|
+
# class doesn't have a default_expiration, the default comes from
|
81
|
+
# Familia.default_expiration (which is 0, aka no-op/skip/do nothing).
|
82
|
+
unless default_expiration.is_a?(Numeric)
|
83
|
+
raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} in #{self.class})"
|
84
|
+
end
|
34
85
|
|
35
|
-
|
36
|
-
|
86
|
+
# If zero, simply skips setting an expiry for this key. If we were to set
|
87
|
+
# 0 the database would drop the key immediately.
|
88
|
+
return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})" if default_expiration.zero?
|
89
|
+
|
90
|
+
Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
|
91
|
+
|
92
|
+
# Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
|
93
|
+
# not exist or the timeout could not be set. Via redis-rb here, it's
|
94
|
+
# a bool.
|
95
|
+
expire(default_expiration)
|
96
|
+
end
|
97
|
+
|
98
|
+
Familia::Base.add_feature self, :expiration
|
37
99
|
end
|
100
|
+
end
|
101
|
+
end
|
38
102
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
#
|
103
|
+
module Familia
|
104
|
+
# Add a default update_expiration method for all classes that include
|
105
|
+
# Familia::Base. Since expiration is a core feature, we can confidently
|
106
|
+
# call `horreum_instance.update_expiration` without defensive programming
|
107
|
+
# even when expiration is not enabled for the horreum_instance class.
|
108
|
+
module Base
|
109
|
+
# Base implementation of update_expiration that maintains API compatibility
|
110
|
+
# with the :expiration feature's implementation.
|
46
111
|
#
|
47
|
-
#
|
48
|
-
#
|
112
|
+
# This is a no-op implementation that gets overridden by features like
|
113
|
+
# :expiration. It accepts an optional default_expiration parameter to maintain interface
|
114
|
+
# compatibility with the overriding implementations.
|
49
115
|
#
|
50
|
-
# @
|
51
|
-
#
|
116
|
+
# @param default_expiration [Integer, nil] Time To Live in seconds
|
117
|
+
# @return [nil] Always returns nil
|
52
118
|
#
|
53
|
-
# @note
|
54
|
-
#
|
55
|
-
#
|
56
|
-
# @raise [Familia::Problem] Raises an error if the default expiration is not a non-negative
|
57
|
-
# integer.
|
119
|
+
# @note This is a no-op implementation. Classes that need expiration
|
120
|
+
# functionality should include the :expiration feature.
|
58
121
|
#
|
59
122
|
def update_expiration(default_expiration: nil)
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
next if definition.opts[:default_expiration].nil?
|
66
|
-
obj = send(name)
|
67
|
-
Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
|
68
|
-
obj.update_expiration(default_expiration: default_expiration)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
# It's important to raise exceptions here and not just log warnings. We
|
73
|
-
# don't want to silently fail at setting expirations and cause data
|
74
|
-
# retention issues (e.g. not removed in a timely fashion).
|
75
|
-
#
|
76
|
-
# For the same reason, we don't want to default to 0 bc there's not a
|
77
|
-
# good reason for the default_expiration to not be set in the first place. If the
|
78
|
-
# class doesn't have a default_expiration, the default comes from Familia.default_expiration (which
|
79
|
-
# is 0).
|
80
|
-
unless default_expiration.is_a?(Numeric)
|
81
|
-
raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} in #{self.class})"
|
82
|
-
end
|
83
|
-
|
84
|
-
if default_expiration.zero?
|
85
|
-
return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})"
|
86
|
-
end
|
87
|
-
|
88
|
-
Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
|
89
|
-
|
90
|
-
# Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
|
91
|
-
# not exist or the timeout could not be set. Via redis-rb here, it's
|
92
|
-
# a bool.
|
93
|
-
expire(default_expiration)
|
123
|
+
Familia.ld <<~LOG
|
124
|
+
[update_expiration] Feature not enabled for #{self.class}.
|
125
|
+
Key: #{dbkey} Arg: #{default_expiration} (caller: #{caller(1..1)})
|
126
|
+
LOG
|
127
|
+
nil
|
94
128
|
end
|
95
|
-
extend ClassMethods
|
96
|
-
|
97
|
-
Familia::Base.add_feature self, :expiration
|
98
129
|
end
|
99
|
-
|
100
130
|
end
|
@@ -1,8 +1,11 @@
|
|
1
1
|
# lib/familia/features/quantization.rb
|
2
2
|
|
3
3
|
module Familia::Features
|
4
|
-
|
5
4
|
module Quantization
|
5
|
+
def self.included(base)
|
6
|
+
Familia.trace :included, base, self, caller(1..1) if Familia.debug?
|
7
|
+
base.extend ClassMethods
|
8
|
+
end
|
6
9
|
|
7
10
|
module ClassMethods
|
8
11
|
# Generates a quantized timestamp based on the given parameters.
|
@@ -25,9 +28,7 @@ module Familia::Features
|
|
25
28
|
#
|
26
29
|
def qstamp(quantum = nil, pattern: nil, time: nil)
|
27
30
|
# Handle default values and array input
|
28
|
-
if quantum.is_a?(Array)
|
29
|
-
quantum, pattern = quantum
|
30
|
-
end
|
31
|
+
quantum, pattern = quantum if quantum.is_a?(Array)
|
31
32
|
|
32
33
|
# Previously we erronously included `@opts.fetch(:quantize, nil)` in
|
33
34
|
# the list of default values here, but @opts is for horreum instances
|
@@ -46,11 +47,6 @@ module Familia::Features
|
|
46
47
|
end
|
47
48
|
end
|
48
49
|
|
49
|
-
def self.included base
|
50
|
-
Familia.ld "[#{base}] Loaded #{self}"
|
51
|
-
base.extend ClassMethods
|
52
|
-
end
|
53
|
-
|
54
50
|
def qstamp(quantum = nil, pattern: nil, time: nil)
|
55
51
|
self.class.qstamp(quantum || self.class.default_expiration, pattern: pattern, time: time)
|
56
52
|
end
|
@@ -9,9 +9,6 @@ module V2
|
|
9
9
|
# Provides the standard core object fields and methods.
|
10
10
|
#
|
11
11
|
module RelatableObject
|
12
|
-
klass = self
|
13
|
-
err_klass = V2::Features::RelatableObjectError
|
14
|
-
|
15
12
|
def self.included(base)
|
16
13
|
base.class_sorted_set :relatable_objids
|
17
14
|
base.class_hashkey :owners
|
@@ -82,7 +79,8 @@ module V2
|
|
82
79
|
|
83
80
|
def owned?
|
84
81
|
# We can only have an owner if we are relatable ourselves.
|
85
|
-
return false unless
|
82
|
+
return false unless is_a?(RelatableObject)
|
83
|
+
|
86
84
|
# If our object identifier is present, we have an owner
|
87
85
|
self.class.owners.key?(objid)
|
88
86
|
end
|
@@ -1,6 +1,5 @@
|
|
1
1
|
# lib/familia/features/safe_dump.rb
|
2
2
|
|
3
|
-
|
4
3
|
module Familia::Features
|
5
4
|
# SafeDump is a mixin that allows models to define a list of fields that are
|
6
5
|
# safe to dump. This is useful for serializing objects to JSON or other
|
@@ -54,6 +53,20 @@ module Familia::Features
|
|
54
53
|
@safe_dump_fields = []
|
55
54
|
@safe_dump_field_map = {}
|
56
55
|
|
56
|
+
def self.included(base)
|
57
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
58
|
+
base.extend ClassMethods
|
59
|
+
|
60
|
+
# Optionally define safe_dump_fields in the class to make
|
61
|
+
# sure we always have an array to work with.
|
62
|
+
base.instance_variable_set(:@safe_dump_fields, []) unless base.instance_variable_defined?(:@safe_dump_fields)
|
63
|
+
|
64
|
+
# Ditto for the field map
|
65
|
+
return if base.instance_variable_defined?(:@safe_dump_field_map)
|
66
|
+
|
67
|
+
base.instance_variable_set(:@safe_dump_field_map, {})
|
68
|
+
end
|
69
|
+
|
57
70
|
module ClassMethods
|
58
71
|
def set_safe_dump_fields(*fields)
|
59
72
|
@safe_dump_fields = fields
|
@@ -100,22 +113,6 @@ module Familia::Features
|
|
100
113
|
end
|
101
114
|
end
|
102
115
|
|
103
|
-
def self.included base
|
104
|
-
Familia.ld "[#{self}] Enabled in #{base}"
|
105
|
-
base.extend ClassMethods
|
106
|
-
|
107
|
-
# Optionally define safe_dump_fields in the class to make
|
108
|
-
# sure we always have an array to work with.
|
109
|
-
unless base.instance_variable_defined?(:@safe_dump_fields)
|
110
|
-
base.instance_variable_set(:@safe_dump_fields, [])
|
111
|
-
end
|
112
|
-
|
113
|
-
# Ditto for the field map
|
114
|
-
unless base.instance_variable_defined?(:@safe_dump_field_map)
|
115
|
-
base.instance_variable_set(:@safe_dump_field_map, {})
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
116
|
# Returns a hash of safe fields and their values. This method
|
120
117
|
# calls the callables defined in the safe_dump_field_map with
|
121
118
|
# the instance object as an argument.
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# lib/familia/features/transient_fields/redacted_string.rb
|
2
|
+
|
3
|
+
# RedactedString
|
4
|
+
#
|
5
|
+
# A secure wrapper for sensitive string values (e.g., API keys, passwords,
|
6
|
+
# encryption keys).
|
7
|
+
# Designed to:
|
8
|
+
# - Prevent accidental logging/inspection
|
9
|
+
# - Enable secure memory wiping
|
10
|
+
# - Encourage safe usage patterns
|
11
|
+
#
|
12
|
+
# ⚠️ IMPORTANT: This is *best-effort* protection. Ruby does not guarantee
|
13
|
+
# memory zeroing. GC, string sharing, and internal optimizations
|
14
|
+
# may leave copies in memory.
|
15
|
+
#
|
16
|
+
# ⚠️ INPUT SECURITY: The constructor calls .dup on the input, creating a copy,
|
17
|
+
# but the original input value remains in memory uncontrolled.
|
18
|
+
# The caller is responsible for securely clearing the original.
|
19
|
+
#
|
20
|
+
# Security Model:
|
21
|
+
# - The secret is *contained* from the moment it's wrapped.
|
22
|
+
# - Access is available via `.expose { }` for controlled use, or `.value` for direct access.
|
23
|
+
# - Manual `.clear!` is required when done with the value (unlike SingleUseRedactedString).
|
24
|
+
# - `.to_s` and `.inspect` return '[REDACTED]' to prevent leaks in logs,
|
25
|
+
# errors, or debugging.
|
26
|
+
#
|
27
|
+
# Critical Gotchas:
|
28
|
+
#
|
29
|
+
# 1. Ruby 3.4+ String Internals — Memory Safety Reality
|
30
|
+
# - Ruby uses "compact strings" and copy-on-write semantics.
|
31
|
+
# - Short strings (< 24 bytes on 64-bit) are *embedded* in the object
|
32
|
+
# (RSTRING_EMBED_LEN).
|
33
|
+
# - Long strings use heap-allocated buffers, but may be shared or
|
34
|
+
# duplicated silently.
|
35
|
+
# - There is *no guarantee* that GC will not copy the string before
|
36
|
+
# finalization.
|
37
|
+
#
|
38
|
+
# 2. Every .dup, .to_s, +, interpolation, or method call may create hidden
|
39
|
+
# copies:
|
40
|
+
# s = "secret"
|
41
|
+
# t = s.dup # New object, same content — now two copies
|
42
|
+
# u = s + "123" # New string — third copy
|
43
|
+
# "#{t}" # Interpolation — fourth copy
|
44
|
+
# These copies are *not* controlled by RedactedString and may persist.
|
45
|
+
#
|
46
|
+
# 3. String Freezing & Immutability
|
47
|
+
# - `.freeze` prevents mutation but does *not* prevent copying.
|
48
|
+
# - `.replace` on a frozen string raises FrozenError — so wiping fails.
|
49
|
+
#
|
50
|
+
# 4. RbNaCl::Util.zero Limitations
|
51
|
+
# - Only works on mutable byte buffers.
|
52
|
+
# - May not zero embedded strings if Ruby's internal representation is
|
53
|
+
# immutable.
|
54
|
+
# - Does *not* protect against memory dumps or GC-compacted heaps.
|
55
|
+
#
|
56
|
+
# 5. Finalizers Are Not Guaranteed
|
57
|
+
# - Ruby does not promise when (or if) `ObjectSpace.define_finalizer`
|
58
|
+
# runs.
|
59
|
+
# - Never rely on finalizers for security-critical wiping.
|
60
|
+
#
|
61
|
+
# Best Practices:
|
62
|
+
# - Wrap secrets *immediately* on input (e.g., from ENV, params, DB).
|
63
|
+
# - Clear original input after wrapping: `secret.clear!` or `secret = nil`
|
64
|
+
# - Use `.expose { }` for short-lived operations — never store plaintext.
|
65
|
+
# - Avoid passing RedactedString to logging, serialization, or debugging
|
66
|
+
# tools.
|
67
|
+
# - Prefer `.expose { }` over any "getter" method.
|
68
|
+
# - Do *not* subclass String — it leaks the underlying value in regex,
|
69
|
+
# case, etc.
|
70
|
+
#
|
71
|
+
# Example:
|
72
|
+
# password_input = params[:password] # Original value in memory
|
73
|
+
# password = RedactedString.new(password_input)
|
74
|
+
# password_input.clear! if password_input.respond_to?(:clear!)
|
75
|
+
# # or: params[:password] = nil # Clear reference (not guaranteed)
|
76
|
+
#
|
77
|
+
class RedactedString
|
78
|
+
# Wrap a sensitive value. The input is *not* wiped — ensure it's not reused.
|
79
|
+
def initialize(original_value)
|
80
|
+
# WARNING: .dup only creates a shallow copy; the original may still exist
|
81
|
+
# elsewhere in memory.
|
82
|
+
@value = original_value.to_s.dup
|
83
|
+
@cleared = false
|
84
|
+
# Do NOT freeze — we need to mutate it in `#clear!`
|
85
|
+
ObjectSpace.define_finalizer(self, self.class.finalizer_proc)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Primary API: expose the value in a block.
|
89
|
+
# The value remains accessible for multiple reads until manually cleared.
|
90
|
+
# Call clear! explicitly when done with the value.
|
91
|
+
#
|
92
|
+
# ⚠️ Security Warning: Avoid .dup, string interpolation, or other operations
|
93
|
+
# that create uncontrolled copies of the sensitive value.
|
94
|
+
#
|
95
|
+
# Example:
|
96
|
+
# token.expose do |plain|
|
97
|
+
# # Good: use directly without copying
|
98
|
+
# HTTP.post('/api', headers: { 'X-Token' => plain })
|
99
|
+
# # Avoid: plain.dup, "prefix#{plain}", plain[0..-1], etc.
|
100
|
+
# end
|
101
|
+
# # Value is still accessible after block
|
102
|
+
# token.clear! # Explicitly clear when done
|
103
|
+
#
|
104
|
+
def expose
|
105
|
+
raise ArgumentError, 'Block required' unless block_given?
|
106
|
+
raise SecurityError, 'Value already cleared' if cleared?
|
107
|
+
|
108
|
+
yield @value
|
109
|
+
end
|
110
|
+
|
111
|
+
# Clear the internal buffer. Safe to call multiple times.
|
112
|
+
#
|
113
|
+
# REALITY CHECK: This doesn't actually provide security in Ruby.
|
114
|
+
# - Ruby may have already copied the string elsewhere in memory
|
115
|
+
# - Garbage collection behavior is unpredictable
|
116
|
+
# - The original input value is still in memory somewhere
|
117
|
+
# - This is primarily for API consistency and preventing reuse
|
118
|
+
def clear!
|
119
|
+
return if @value.nil? || @value.frozen? || @cleared
|
120
|
+
|
121
|
+
# Simple clear - no security theater
|
122
|
+
@value.clear if @value.respond_to?(:clear)
|
123
|
+
@value = nil
|
124
|
+
@cleared = true
|
125
|
+
freeze # one and done
|
126
|
+
end
|
127
|
+
|
128
|
+
# Get the actual value (for convenience in less sensitive contexts)
|
129
|
+
# Returns the wrapped value or nil if cleared
|
130
|
+
#
|
131
|
+
# ⚠️ Security Warning: Direct access bypasses the controlled exposure pattern.
|
132
|
+
# Prefer .expose { } for better security practices.
|
133
|
+
def value
|
134
|
+
raise SecurityError, 'Value already cleared' if cleared?
|
135
|
+
|
136
|
+
@value
|
137
|
+
end
|
138
|
+
|
139
|
+
# Always redact in logs, debugging, or string conversion
|
140
|
+
def to_s = '[REDACTED]'
|
141
|
+
def inspect = to_s
|
142
|
+
def cleared? = @cleared
|
143
|
+
|
144
|
+
# Returns true when it's literally the same object, otherwise false.
|
145
|
+
# This prevents timing attacks where an attacker could potentially
|
146
|
+
# infer information about the secret value through comparison timing
|
147
|
+
def ==(other)
|
148
|
+
object_id.equal?(other.object_id) # same object
|
149
|
+
end
|
150
|
+
alias eql? ==
|
151
|
+
|
152
|
+
# All RedactedString instances have the same hash to prevent
|
153
|
+
# hash-based timing attacks or information leakage
|
154
|
+
def hash
|
155
|
+
RedactedString.hash
|
156
|
+
end
|
157
|
+
|
158
|
+
def self.finalizer_proc = proc { |id| }
|
159
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# lib/familia/features/transient_fields/single_use_redacted_string.rb
|
2
|
+
|
3
|
+
require_relative 'redacted_string'
|
4
|
+
|
5
|
+
# SingleUseRedactedString
|
6
|
+
#
|
7
|
+
# A high-security variant of RedactedString that automatically clears
|
8
|
+
# its value after a single use via the expose method. Unlike RedactedString,
|
9
|
+
# this provides automatic cleanup and enforces single-use semantics.
|
10
|
+
#
|
11
|
+
# ⚠️ IMPORTANT: Inherits all security limitations from RedactedString regarding
|
12
|
+
# Ruby's memory management and copy-on-write semantics.
|
13
|
+
#
|
14
|
+
# ⚠️ INPUT SECURITY: Like RedactedString, the constructor calls .dup on input,
|
15
|
+
# creating a copy, but the original input remains in memory.
|
16
|
+
# The caller is responsible for securely clearing the original.
|
17
|
+
#
|
18
|
+
# Key Differences from RedactedString:
|
19
|
+
# - Automatically clears after expose() (no manual clear! needed)
|
20
|
+
# - Blocks direct value() access (prevents accidental multi-use)
|
21
|
+
# - Raises SecurityError on second expose() attempt
|
22
|
+
#
|
23
|
+
# Use this for extremely sensitive values that should only be accessed
|
24
|
+
# once, such as:
|
25
|
+
# - One-time passwords (OTPs)
|
26
|
+
# - Temporary authentication tokens
|
27
|
+
# - Encryption keys that should be immediately discarded
|
28
|
+
#
|
29
|
+
# Example:
|
30
|
+
# otp_input = params[:otp] # Original value in memory
|
31
|
+
# otp = SingleUseRedactedString.new(otp_input)
|
32
|
+
# params[:otp] = nil # Clear reference (not guaranteed)
|
33
|
+
# otp.expose do |code|
|
34
|
+
# verify_otp(code) # Use directly without copying
|
35
|
+
# end
|
36
|
+
# # Value is automatically cleared after block
|
37
|
+
# otp.cleared? #=> true
|
38
|
+
#
|
39
|
+
class SingleUseRedactedString < RedactedString
|
40
|
+
# Override expose to automatically clear after use
|
41
|
+
#
|
42
|
+
# This ensures the value can only be accessed once via expose,
|
43
|
+
# providing maximum security for single-use secrets.
|
44
|
+
#
|
45
|
+
def expose
|
46
|
+
raise ArgumentError, 'Block required' unless block_given?
|
47
|
+
raise SecurityError, 'Value already cleared' if cleared?
|
48
|
+
|
49
|
+
yield @value
|
50
|
+
ensure
|
51
|
+
clear! # Automatically clear after single use
|
52
|
+
end
|
53
|
+
|
54
|
+
# Override value accessor to prevent direct access
|
55
|
+
#
|
56
|
+
# For single-use secrets, we don't want to allow direct value access
|
57
|
+
# to maintain the single-use guarantee.
|
58
|
+
#
|
59
|
+
def value
|
60
|
+
raise SecurityError, 'Direct value access not allowed for single-use secrets. Use #expose with a block.'
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
# lib/familia/features/transient_fields/transient_field_type.rb
|
2
|
+
|
3
|
+
require 'familia/field_type'
|
4
|
+
|
5
|
+
require_relative 'redacted_string'
|
6
|
+
|
7
|
+
module Familia
|
8
|
+
# TransientFieldType - Fields that are not persisted to database
|
9
|
+
#
|
10
|
+
# Transient fields automatically wrap values in RedactedString for security
|
11
|
+
# and are excluded from serialization operations. They are ideal for storing
|
12
|
+
# sensitive data like API keys, passwords, and tokens that should not be
|
13
|
+
# persisted to the database.
|
14
|
+
#
|
15
|
+
# @example Using transient fields
|
16
|
+
# class SecretService < Familia::Horreum
|
17
|
+
# field :name # Regular field
|
18
|
+
# transient_field :api_key # Wrapped in RedactedString
|
19
|
+
# transient_field :password # Not persisted to database
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# service = SecretService.new
|
23
|
+
# service.api_key = "sk-1234567890"
|
24
|
+
# service.api_key.class #=> RedactedString
|
25
|
+
# service.to_h #=> {:name => nil} (no api_key)
|
26
|
+
#
|
27
|
+
class TransientFieldType < FieldType
|
28
|
+
# Override setter to wrap values in RedactedString
|
29
|
+
#
|
30
|
+
# Values are automatically wrapped in RedactedString objects for security.
|
31
|
+
# Nil values and existing RedactedString objects are handled appropriately.
|
32
|
+
#
|
33
|
+
# @param klass [Class] The class to define the method on
|
34
|
+
#
|
35
|
+
def define_setter(klass)
|
36
|
+
field_name = @name
|
37
|
+
method_name = @method_name
|
38
|
+
|
39
|
+
handle_method_conflict(klass, :"#{method_name}=") do
|
40
|
+
klass.define_method :"#{method_name}=" do |value|
|
41
|
+
wrapped = if value.nil?
|
42
|
+
nil
|
43
|
+
elsif value.is_a?(RedactedString)
|
44
|
+
value
|
45
|
+
else
|
46
|
+
RedactedString.new(value)
|
47
|
+
end
|
48
|
+
instance_variable_set(:"@#{field_name}", wrapped)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Override getter to unwrap RedactedString values
|
54
|
+
#
|
55
|
+
# Returns the actual value from the RedactedString wrapper for
|
56
|
+
# convenient access, or nil if the value is nil or cleared.
|
57
|
+
#
|
58
|
+
# @param klass [Class] The class to define the method on
|
59
|
+
#
|
60
|
+
def define_getter(klass)
|
61
|
+
field_name = @name
|
62
|
+
method_name = @method_name
|
63
|
+
|
64
|
+
handle_method_conflict(klass, method_name) do
|
65
|
+
klass.define_method method_name do
|
66
|
+
wrapped = instance_variable_get(:"@#{field_name}")
|
67
|
+
return nil if wrapped.nil? || wrapped.cleared?
|
68
|
+
|
69
|
+
wrapped
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Override fast writer to disable it for transient fields
|
75
|
+
#
|
76
|
+
# Transient fields should not have fast writers since they're not
|
77
|
+
# persisted to the database.
|
78
|
+
#
|
79
|
+
# @param klass [Class] The class to define the method on
|
80
|
+
#
|
81
|
+
def define_fast_writer(_klass)
|
82
|
+
# No fast writer for transient fields since they're not persisted
|
83
|
+
Familia.ld "[TransientFieldType] Skipping fast writer for transient field: #{@name}"
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
|
87
|
+
# Transient fields are not persisted to database
|
88
|
+
#
|
89
|
+
# @return [Boolean] false - transient fields are never persisted
|
90
|
+
#
|
91
|
+
def persistent?
|
92
|
+
false
|
93
|
+
end
|
94
|
+
|
95
|
+
# A convenience method that wraps `persistent?`
|
96
|
+
#
|
97
|
+
def transient?
|
98
|
+
!persistent?
|
99
|
+
end
|
100
|
+
|
101
|
+
# Category for transient fields
|
102
|
+
#
|
103
|
+
# @return [Symbol] :transient
|
104
|
+
#
|
105
|
+
def category
|
106
|
+
:transient
|
107
|
+
end
|
108
|
+
|
109
|
+
# Transient fields are not serialized to database
|
110
|
+
#
|
111
|
+
# This method should not be called since transient fields are not
|
112
|
+
# persisted, but we provide it for completeness.
|
113
|
+
#
|
114
|
+
# @param value [Object] The value to serialize
|
115
|
+
# @param record [Object] The record instance
|
116
|
+
# @return [nil] Always nil since transient fields are not serialized
|
117
|
+
#
|
118
|
+
def serialize(_value, _record = nil)
|
119
|
+
# Transient fields should never be serialized
|
120
|
+
Familia.ld "[TransientFieldType] WARNING: serialize called on transient field #{@name}"
|
121
|
+
nil
|
122
|
+
end
|
123
|
+
|
124
|
+
# Transient fields are not deserialized from database
|
125
|
+
#
|
126
|
+
# This method should not be called since transient fields are not
|
127
|
+
# persisted, but we provide it for completeness.
|
128
|
+
#
|
129
|
+
# @param value [Object] The value to deserialize
|
130
|
+
# @param record [Object] The record instance
|
131
|
+
# @return [nil] Always nil since transient fields are not stored
|
132
|
+
#
|
133
|
+
def deserialize(_value, _record = nil)
|
134
|
+
# Transient fields should never be deserialized
|
135
|
+
Familia.ld "[TransientFieldType] WARNING: deserialize called on transient field #{@name}"
|
136
|
+
nil
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|