familia 2.0.0.pre4 → 2.0.0.pre5

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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +3 -3
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +18 -3
  7. data/README.md +36 -157
  8. data/TEST_COVERAGE.md +40 -0
  9. data/docs/overview.md +359 -0
  10. data/docs/wiki/API-Reference.md +270 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
  12. data/docs/wiki/Home.md +49 -0
  13. data/docs/wiki/Implementation-Guide.md +183 -0
  14. data/docs/wiki/Security-Model.md +143 -0
  15. data/lib/familia/base.rb +18 -27
  16. data/lib/familia/connection.rb +6 -5
  17. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  18. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  19. data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
  20. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  21. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  22. data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
  23. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  24. data/lib/familia/{datatype.rb → data_type.rb} +10 -12
  25. data/lib/familia/encryption/manager.rb +102 -0
  26. data/lib/familia/encryption/provider.rb +49 -0
  27. data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
  28. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  29. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
  30. data/lib/familia/encryption/registry.rb +50 -0
  31. data/lib/familia/encryption.rb +178 -0
  32. data/lib/familia/encryption_request_cache.rb +68 -0
  33. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
  34. data/lib/familia/features/encrypted_fields.rb +28 -0
  35. data/lib/familia/features/expiration.rb +107 -77
  36. data/lib/familia/features/quantization.rb +5 -9
  37. data/lib/familia/features/relatable_objects.rb +2 -4
  38. data/lib/familia/features/safe_dump.rb +14 -17
  39. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  40. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  41. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  42. data/lib/familia/features/transient_fields.rb +47 -0
  43. data/lib/familia/features.rb +40 -24
  44. data/lib/familia/field_type.rb +270 -0
  45. data/lib/familia/horreum/connection.rb +8 -11
  46. data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
  47. data/lib/familia/horreum/definition_methods.rb +453 -0
  48. data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -243
  49. data/lib/familia/horreum/serialization.rb +46 -18
  50. data/lib/familia/horreum/settings.rb +10 -2
  51. data/lib/familia/horreum/utils.rb +9 -10
  52. data/lib/familia/horreum.rb +18 -10
  53. data/lib/familia/logging.rb +14 -14
  54. data/lib/familia/settings.rb +39 -3
  55. data/lib/familia/utils.rb +45 -0
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -1
  58. data/try/core/base_enhancements_try.rb +115 -0
  59. data/try/core/connection_try.rb +0 -1
  60. data/try/core/errors_try.rb +0 -1
  61. data/try/core/familia_extended_try.rb +3 -4
  62. data/try/core/familia_try.rb +0 -1
  63. data/try/core/pools_try.rb +2 -2
  64. data/try/core/secure_identifier_try.rb +0 -1
  65. data/try/core/settings_try.rb +0 -1
  66. data/try/core/utils_try.rb +0 -1
  67. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  68. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  69. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  70. data/try/{datatypes → data_types}/list_try.rb +1 -2
  71. data/try/{datatypes → data_types}/set_try.rb +1 -2
  72. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  73. data/try/{datatypes → data_types}/string_try.rb +1 -2
  74. data/try/debugging/README.md +32 -0
  75. data/try/debugging/cache_behavior_tracer.rb +91 -0
  76. data/try/debugging/encryption_method_tracer.rb +138 -0
  77. data/try/debugging/provider_diagnostics.rb +110 -0
  78. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  79. data/try/edge_cases/json_serialization_try.rb +0 -1
  80. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  81. data/try/encryption/config_persistence_try.rb +192 -0
  82. data/try/encryption/encryption_core_try.rb +328 -0
  83. data/try/encryption/instance_variable_scope_try.rb +31 -0
  84. data/try/encryption/module_loading_try.rb +28 -0
  85. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  86. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  87. data/try/encryption/roundtrip_validation_try.rb +28 -0
  88. data/try/encryption/secure_memory_handling_try.rb +125 -0
  89. data/try/features/encrypted_fields_core_try.rb +117 -0
  90. data/try/features/encrypted_fields_integration_try.rb +220 -0
  91. data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
  92. data/try/features/encrypted_fields_security_try.rb +370 -0
  93. data/try/features/encryption_fields/aad_protection_try.rb +53 -0
  94. data/try/features/encryption_fields/context_isolation_try.rb +120 -0
  95. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  96. data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
  97. data/try/features/encryption_fields/fresh_key_try.rb +163 -0
  98. data/try/features/encryption_fields/key_rotation_try.rb +117 -0
  99. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  100. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  101. data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
  102. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  103. data/try/features/expiration_try.rb +0 -1
  104. data/try/features/feature_dependencies_try.rb +159 -0
  105. data/try/features/quantization_try.rb +0 -1
  106. data/try/features/real_feature_integration_try.rb +148 -0
  107. data/try/features/relatable_objects_try.rb +0 -1
  108. data/try/features/safe_dump_advanced_try.rb +0 -1
  109. data/try/features/safe_dump_try.rb +0 -1
  110. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  111. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  112. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  113. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  114. data/try/features/transient_fields_core_try.rb +181 -0
  115. data/try/features/transient_fields_integration_try.rb +260 -0
  116. data/try/helpers/test_helpers.rb +42 -0
  117. data/try/horreum/base_try.rb +157 -3
  118. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  119. data/try/horreum/field_categories_try.rb +118 -0
  120. data/try/horreum/field_definition_try.rb +96 -0
  121. data/try/horreum/initialization_try.rb +0 -1
  122. data/try/horreum/relations_try.rb +0 -1
  123. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  124. data/try/horreum/serialization_try.rb +2 -3
  125. data/try/memory/memory_basic_test.rb +73 -0
  126. data/try/memory/memory_detailed_test.rb +121 -0
  127. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  128. data/try/memory/memory_search_for_string.rb +83 -0
  129. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  130. data/try/models/customer_safe_dump_try.rb +0 -1
  131. data/try/models/customer_try.rb +0 -1
  132. data/try/models/datatype_base_try.rb +1 -2
  133. data/try/models/familia_object_try.rb +0 -1
  134. metadata +85 -18
@@ -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.ld "[#{base}] Loaded #{self}"
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 self.is_a?(RelatableObject)
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.ld "[#{self}] Enabled in #{base}"
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, otherwsie 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
@@ -0,0 +1,47 @@
1
+ # lib/familia/features/transient_fields.rb
2
+
3
+ require_relative 'transient_fields/redacted_string'
4
+
5
+ module Familia
6
+ module Features
7
+ # Familia::Features::TransientFields
8
+ #
9
+ # Provides secure transient fields that wrap sensitive values in RedactedString
10
+ # objects. These fields are excluded from serialization operations and provide
11
+ # automatic memory wiping for security.
12
+ #
13
+ module TransientFields
14
+ def self.included(base)
15
+ Familia.ld "[#{base}] Loaded #{self}"
16
+ base.extend ClassMethods
17
+ end
18
+
19
+ # ClassMethods
20
+ #
21
+ module ClassMethods
22
+ # Define a transient field that automatically wraps values in RedactedString
23
+ #
24
+ # @param name [Symbol] The field name
25
+ # @param as [Symbol] The method name (defaults to field name)
26
+ # @param kwargs [Hash] Additional field options
27
+ #
28
+ # @example Define a transient API key field
29
+ # class Service < Familia::Horreum
30
+ # feature :transient_fields
31
+ # transient_field :api_key
32
+ # end
33
+ #
34
+ def transient_field(name, as: name, **kwargs)
35
+ # Use the field type system - much cleaner than alias_method approach!
36
+ # We can now remove the transient_field method from this feature entirely
37
+ # since it's built into DefinitionMethods using TransientFieldType
38
+ require_relative 'transient_fields/transient_field_type'
39
+ field_type = TransientFieldType.new(name, as: as, **kwargs.merge(fast_method: false))
40
+ register_field_type(field_type)
41
+ end
42
+ end
43
+
44
+ Familia::Base.add_feature self, :transient_fields, depends_on: nil
45
+ end
46
+ end
47
+ end
@@ -2,45 +2,61 @@
2
2
 
3
3
  module Familia
4
4
 
5
+ FeatureDefinition = Data.define(:name, :depends_on)
6
+
7
+ # Familia::Features
8
+ #
5
9
  module Features
6
10
 
7
11
  @features_enabled = nil
8
12
  attr_reader :features_enabled
9
13
 
10
- def feature(val = nil)
14
+ def feature(feature_name = nil)
11
15
  @features_enabled ||= []
12
16
 
13
- # If there's a value provied check that it's a valid feature
14
- if val
15
- val = val.to_sym
16
- raise Familia::Problem, "Unsupported feature: #{val}" unless Familia::Base.features.key?(val)
17
+ return features_enabled if feature_name.nil?
17
18
 
18
- # If the feature is already enabled, do nothing but log about it
19
- if @features_enabled.member?(val)
20
- Familia.warn "[Familia::Settings] feature already enabled: #{val}"
21
- return
22
- end
19
+ # If there's a value provied check that it's a valid feature
20
+ feature_name = feature_name.to_sym
21
+ unless Familia::Base.features_available.key?(feature_name)
22
+ raise Familia::Problem, "Unsupported feature: #{feature_name}"
23
+ end
23
24
 
24
- Familia.trace :FEATURE, nil, "#{self} includes #{val.inspect}", caller(1..1) if Familia.debug?
25
+ # If the feature is already available, do nothing but log about it
26
+ if features_enabled.member?(feature_name)
27
+ Familia.warn "[#{self.class}] feature already available: #{feature_name}"
28
+ return
29
+ end
25
30
 
26
- klass = Familia::Base.features[val]
31
+ if Familia.debug?
32
+ Familia.trace :FEATURE, nil, "#{self} includes #{feature_name.inspect}", caller(1..1)
33
+ end
27
34
 
28
- # Extend the Familia::Base subclass (e.g. Customer) with the feature module
29
- include klass
35
+ # Add it to the list available features_enabled for Familia::Base classes.
36
+ features_enabled << feature_name
30
37
 
31
- # NOTE: We may also want to extend Familia::DataType here so that we can
32
- # call safe_dump on relations fields (e.g. list, set, zset, hashkey). Or
33
- # maybe that only makes sense for hashk/object relations.
34
- #
35
- # We'd need to avoid it getting included multiple times (i.e. once for each
36
- # Familia::Horreum subclass that includes the feature).
38
+ klass = Familia::Base.features_available[feature_name]
37
39
 
38
- # Now that the feature is loaded successfully, add it to the list
39
- # enabled features for Familia::Base classes.
40
- @features_enabled << val
40
+ # Validate dependencies
41
+ feature_def = Familia::Base.feature_definitions[feature_name]
42
+ if feature_def&.depends_on&.any?
43
+ missing = feature_def.depends_on - features_enabled
44
+ raise Familia::Problem, "#{feature_name} requires: #{missing.join(', ')}" if missing.any?
41
45
  end
42
46
 
43
- features_enabled
47
+ # Extend the Familia::Base subclass (e.g. Customer) with the feature module
48
+ include klass
49
+
50
+ # NOTE: Do we want to extend Familia::DataType here? That would make it
51
+ # possible to call safe_dump on relations fields (e.g. list, zset, hashkey).
52
+ #
53
+ # The challenge is that DataType classes (List, Set, etc.) are shared across
54
+ # all Horreum models. If Customer extends DataType with safe_dump, then
55
+ # Session's lists would also have it. Not ideal. If that's all we wanted
56
+ # then we can do that by looping through every DataType class here.
57
+ #
58
+ # We'd need to extend the DataType instances for each Horreum subclass. That
59
+ # avoids it getting included multiple times per DataType
44
60
  end
45
61
 
46
62
  end