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
@@ -0,0 +1,50 @@
1
+ # lib/familia/encryption/registry.rb
2
+
3
+ module Familia
4
+ module Encryption
5
+ # Registry pattern for managing encryption providers
6
+ class Registry
7
+ class << self
8
+ def providers
9
+ @providers ||= {}
10
+ end
11
+
12
+ def register(provider_class)
13
+ return unless provider_class.available?
14
+
15
+ providers[provider_class::ALGORITHM] = provider_class
16
+ end
17
+
18
+ def get(algorithm)
19
+ provider_class = providers[algorithm]
20
+ raise EncryptionError, "Unsupported algorithm: #{algorithm}" unless provider_class
21
+
22
+ provider_class.new
23
+ end
24
+
25
+ def default_provider
26
+ # Select provider with highest priority
27
+ @default_provider ||= begin
28
+ available = providers.values.select(&:available?)
29
+ available.max_by(&:priority)&.new
30
+ end
31
+ end
32
+
33
+ def reset_default_provider!
34
+ @default_provider = nil
35
+ end
36
+
37
+ def available_algorithms
38
+ providers.keys
39
+ end
40
+
41
+ # Auto-register known providers
42
+ def setup!
43
+ register(Providers::XChaCha20Poly1305Provider)
44
+ register(Providers::AESGCMProvider)
45
+ # Future: register(Providers::ChaCha20Poly1305Provider)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,178 @@
1
+ # lib/familia/encryption.rb
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'openssl'
6
+
7
+ # Provider system components
8
+ require_relative 'encryption/provider'
9
+ require_relative 'encryption/providers/xchacha20_poly1305_provider'
10
+ require_relative 'encryption/providers/aes_gcm_provider'
11
+ require_relative 'encryption/registry'
12
+ require_relative 'encryption/manager'
13
+
14
+ module Familia
15
+ class EncryptionError < StandardError; end
16
+
17
+ module Encryption
18
+ EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version)
19
+
20
+ # Smart facade with provider selection and field-specific encryption
21
+ #
22
+ # Usage in EncryptedFieldType can now be more flexible:
23
+ #
24
+ # module Familia
25
+ # class EncryptedFieldType < FieldType
26
+ # attr_reader :algorithm # Optional algorithm override
27
+ #
28
+ # def initialize(name, aad_fields: [], algorithm: nil, **options)
29
+ # super(name, **options.merge(on_conflict: :raise))
30
+ # @aad_fields = Array(aad_fields).freeze
31
+ # @algorithm = algorithm # Use specific algorithm for this field
32
+ # end
33
+ #
34
+ # def encrypt_value(record, value)
35
+ # context = build_context(record)
36
+ # additional_data = build_aad(record)
37
+ #
38
+ # if @algorithm
39
+ # # Use specific algorithm for this field
40
+ # Familia::Encryption.encrypt_with(@algorithm, value,
41
+ # context: context,
42
+ # additional_data: additional_data)
43
+ # else
44
+ # # Use default best algorithm
45
+ # Familia::Encryption.encrypt(value,
46
+ # context: context,
47
+ # additional_data: additional_data)
48
+ # end
49
+ # end
50
+ #
51
+ # # Decrypt auto-detects algorithm from data, so no change needed
52
+ # def decrypt_value(record, encrypted)
53
+ # context = build_context(record)
54
+ # additional_data = build_aad(record)
55
+ #
56
+ # Familia::Encryption.decrypt(encrypted,
57
+ # context: context,
58
+ # additional_data: additional_data)
59
+ # end
60
+ # end
61
+ # end
62
+ class << self
63
+ # Get or create a manager with specific algorithm
64
+ def manager(algorithm: nil)
65
+ @managers ||= {}
66
+ @managers[algorithm] ||= Manager.new(algorithm: algorithm)
67
+ end
68
+
69
+ # Quick encryption with auto-selected best provider
70
+ def encrypt(plaintext, context:, additional_data: nil)
71
+ manager.encrypt(plaintext, context: context, additional_data: additional_data)
72
+ end
73
+
74
+ # Quick decryption (auto-detects algorithm from data)
75
+ def decrypt(encrypted_json, context:, additional_data: nil)
76
+ manager.decrypt(encrypted_json, context: context, additional_data: additional_data)
77
+ end
78
+
79
+ # Encrypt with specific algorithm
80
+ def encrypt_with(algorithm, plaintext, context:, additional_data: nil)
81
+ manager(algorithm: algorithm).encrypt(
82
+ plaintext,
83
+ context: context,
84
+ additional_data: additional_data
85
+ )
86
+ end
87
+
88
+ # Derivation counter for monitoring no-caching behavior
89
+ def derivation_count
90
+ @derivation_count ||= Concurrent::AtomicFixnum.new(0)
91
+ end
92
+
93
+ def reset_derivation_count!
94
+ derivation_count.value = 0
95
+ end
96
+
97
+ # Clear key from memory (no security guarantees in Ruby)
98
+ def secure_wipe(key)
99
+ key&.clear
100
+ end
101
+
102
+ # Get info about current encryption setup
103
+ def status
104
+ Registry.setup! if Registry.providers.empty?
105
+
106
+ {
107
+ default_algorithm: Registry.default_provider&.algorithm,
108
+ available_algorithms: Registry.available_algorithms,
109
+ preferred_available: Registry.default_provider&.class&.name,
110
+ using_hardware: hardware_acceleration?,
111
+ key_versions: encryption_keys.keys,
112
+ current_version: current_key_version
113
+ }
114
+ end
115
+
116
+ # Check if we're using hardware acceleration
117
+ def hardware_acceleration?
118
+ provider = Registry.default_provider
119
+ provider && provider.class.name.include?('Hardware')
120
+ end
121
+
122
+ # Benchmark available providers
123
+ def benchmark(iterations: 1000)
124
+ require 'benchmark'
125
+ test_data = 'x' * 1024 # 1KB test
126
+ context = 'benchmark:test'
127
+
128
+ results = {}
129
+ Registry.providers.each do |algo, provider_class|
130
+ next unless provider_class.available?
131
+
132
+ mgr = Manager.new(algorithm: algo)
133
+ time = Benchmark.realtime do
134
+ iterations.times do
135
+ encrypted = mgr.encrypt(test_data, context: context)
136
+ mgr.decrypt(encrypted, context: context)
137
+ end
138
+ end
139
+
140
+ results[algo] = {
141
+ time: time,
142
+ ops_per_sec: (iterations * 2 / time).round,
143
+ priority: provider_class.priority
144
+ }
145
+ end
146
+
147
+ results
148
+ end
149
+
150
+ def validate_configuration!
151
+ raise EncryptionError, 'No encryption keys configured' if encryption_keys.empty?
152
+ raise EncryptionError, 'No current key version set' unless current_key_version
153
+
154
+ current_key = encryption_keys[current_key_version]
155
+ raise EncryptionError, "Current key version not found: #{current_key_version}" unless current_key
156
+
157
+ begin
158
+ Base64.strict_decode64(current_key)
159
+ rescue ArgumentError
160
+ raise EncryptionError, 'Current encryption key is not valid Base64'
161
+ end
162
+
163
+ Registry.setup!
164
+ raise EncryptionError, 'No encryption providers available' unless Registry.default_provider
165
+ end
166
+
167
+ private
168
+
169
+ def encryption_keys
170
+ Familia.config.encryption_keys || {}
171
+ end
172
+
173
+ def current_key_version
174
+ Familia.config.current_key_version
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,68 @@
1
+ # lib/familia/encryption_request_cache.rb
2
+ #
3
+ # Request-scoped caching for encryption keys (if needed for performance)
4
+ # This should ONLY be enabled if performance testing shows it's necessary
5
+ #
6
+ # Usage in Rack middleware:
7
+ # class ClearEncryptionCacheMiddleware
8
+ # def call(env)
9
+ # Familia::Encryption.clear_request_cache!
10
+ # @app.call(env)
11
+ # ensure
12
+ # Familia::Encryption.clear_request_cache!
13
+ # end
14
+ # end
15
+
16
+ module Familia
17
+ module Encryption
18
+ class << self
19
+ # Enable request-scoped caching (opt-in for performance)
20
+ def with_request_cache
21
+ Thread.current[:familia_request_cache_enabled] = true
22
+ Thread.current[:familia_request_cache] = {}
23
+ yield
24
+ ensure
25
+ clear_request_cache!
26
+ end
27
+
28
+ # Clear all cached keys and disable caching
29
+ def clear_request_cache!
30
+ if (cache = Thread.current[:familia_request_cache])
31
+ cache.each_value { |key| secure_wipe(key) }
32
+ cache.clear
33
+ end
34
+ Thread.current[:familia_request_cache_enabled] = false
35
+ Thread.current[:familia_request_cache] = nil
36
+ end
37
+
38
+ private
39
+
40
+ # Modified derive_key that uses request cache when enabled
41
+ def derive_key_with_optional_cache(context, version: nil)
42
+ version ||= current_key_version
43
+ master_key = get_master_key(version)
44
+
45
+ # Only use cache if explicitly enabled for this request
46
+ if Thread.current[:familia_request_cache_enabled]
47
+ cache = Thread.current[:familia_request_cache] ||= {}
48
+ cache_key = "#{version}:#{context}"
49
+
50
+ # Return cached key if available (within same request only)
51
+ if (cached = cache[cache_key])
52
+ return cached.dup
53
+ end
54
+
55
+ # Derive and cache for this request only
56
+ derived = perform_key_derivation(master_key, context)
57
+ cache[cache_key] = derived.dup
58
+ derived
59
+ else
60
+ # Default: no caching for maximum security
61
+ perform_key_derivation(master_key, context)
62
+ end
63
+ ensure
64
+ secure_wipe(master_key) if master_key
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,153 @@
1
+ # lib/familia/field_types/encrypted_field_type.rb
2
+
3
+ require_relative '../../field_type'
4
+
5
+ module Familia
6
+ class EncryptedFieldType < FieldType
7
+ attr_reader :aad_fields
8
+
9
+ def initialize(name, aad_fields: [], **options)
10
+ super(name, **options.merge(on_conflict: :raise))
11
+ @aad_fields = Array(aad_fields).freeze
12
+ end
13
+
14
+ def define_setter(klass)
15
+ field_name = @name
16
+ method_name = @method_name
17
+ field_type = self
18
+
19
+ handle_method_conflict(klass, :"#{method_name}=") do
20
+ klass.define_method :"#{method_name}=" do |value|
21
+ encrypted = value.nil? ? nil : field_type.encrypt_value(self, value)
22
+ instance_variable_set(:"@#{field_name}", encrypted)
23
+ end
24
+ end
25
+ end
26
+
27
+ def define_getter(klass)
28
+ field_name = @name
29
+ method_name = @method_name
30
+ field_type = self
31
+
32
+ handle_method_conflict(klass, method_name) do
33
+ klass.define_method method_name do
34
+ encrypted = instance_variable_get(:"@#{field_name}")
35
+ encrypted.nil? ? nil : field_type.decrypt_value(self, encrypted)
36
+ end
37
+ end
38
+ end
39
+
40
+ def define_fast_writer(klass)
41
+ # Encrypted fields override base fast writer for security
42
+ return unless @fast_method_name&.to_s&.end_with?('!')
43
+
44
+ field_name = @name
45
+ method_name = @method_name
46
+ fast_method_name = @fast_method_name
47
+ field_type = self
48
+
49
+ handle_method_conflict(klass, fast_method_name) do
50
+ klass.define_method fast_method_name do |val|
51
+ raise ArgumentError, "#{fast_method_name} requires a value" if val.nil?
52
+
53
+ encrypted = field_type.encrypt_value(self, val)
54
+ send(:"#{method_name}=", val) if method_name
55
+
56
+ ret = hset(field_name, encrypted)
57
+ ret.zero? || ret.positive?
58
+ end
59
+ end
60
+ end
61
+
62
+ # Encrypt a value for the given record
63
+ def encrypt_value(record, value)
64
+ context = build_context(record)
65
+ additional_data = build_aad(record)
66
+
67
+ Familia::Encryption.encrypt(value, context: context, additional_data: additional_data)
68
+ end
69
+
70
+ # Decrypt a value for the given record
71
+ def decrypt_value(record, encrypted)
72
+ context = build_context(record)
73
+ additional_data = build_aad(record)
74
+
75
+ Familia::Encryption.decrypt(encrypted, context: context, additional_data: additional_data)
76
+ end
77
+
78
+ def persistent?
79
+ true
80
+ end
81
+
82
+ def category
83
+ :encrypted
84
+ end
85
+
86
+ private
87
+
88
+ # Build encryption context string
89
+ def build_context(record)
90
+ "#{record.class.name}:#{@name}:#{record.identifier}"
91
+ end
92
+
93
+ # Build Additional Authenticated Data (AAD) for authenticated encryption
94
+ #
95
+ # AAD provides cryptographic binding between encrypted field values and their
96
+ # containing record context. This prevents attackers from moving encrypted
97
+ # values between different records or field contexts, even with database access.
98
+ #
99
+ # ## Persistence-Dependent Behavior
100
+ #
101
+ # AAD is only generated for records that exist in the database (`record.exists?`).
102
+ # This creates an important behavioral distinction:
103
+ #
104
+ # **Before Save (record.exists? == false):**
105
+ # - AAD = nil
106
+ # - Encryption context = "ClassName:fieldname:identifier" only
107
+ # - Values can be encrypted/decrypted freely in memory
108
+ #
109
+ # **After Save (record.exists? == true):**
110
+ # - AAD = record.identifier (no aad_fields) or SHA256(identifier:field1:field2:...)
111
+ # - Full cryptographic binding to database state
112
+ # - Moving encrypted values between records/contexts will fail decryption
113
+ #
114
+ # ## Security Implications
115
+ #
116
+ # This design prevents several attack vectors:
117
+ #
118
+ # 1. **Field Value Swapping**: With aad_fields specified, encrypted values
119
+ # become bound to other field values. Changing owner_id breaks decryption.
120
+ #
121
+ # 2. **Cross-Record Migration**: Even without aad_fields, encrypted values
122
+ # are bound to their specific record identifier after persistence.
123
+ #
124
+ # 3. **Temporal Consistency**: Re-encrypting the same plaintext after
125
+ # field changes produces different ciphertext due to AAD changes.
126
+ #
127
+ # ## Usage Patterns
128
+ #
129
+ # ```ruby
130
+ # # No AAD fields - basic record binding
131
+ # encrypted_field :secret_value
132
+ #
133
+ # # With AAD fields - multi-field binding
134
+ # encrypted_field :content, aad_fields: [:owner_id, :doc_type]
135
+ # ```
136
+ #
137
+ # @param record [Familia::Horreum] The record instance containing this field
138
+ # @return [String, nil] AAD string for encryption, or nil for unsaved records
139
+ #
140
+ def build_aad(record)
141
+ return nil unless record.exists?
142
+
143
+ if @aad_fields.empty?
144
+ # When no AAD fields specified, just use identifier
145
+ record.identifier
146
+ else
147
+ # Include specified field values in AAD
148
+ values = @aad_fields.map { |field| record.send(field) }
149
+ Digest::SHA256.hexdigest([record.identifier, *values].compact.join(':'))
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,28 @@
1
+ # lib/familia/features/encrypted_fields.rb
2
+
3
+ require_relative 'encrypted_fields/encrypted_field_type'
4
+
5
+ module Familia
6
+ module Features
7
+ module EncryptedFields
8
+ def self.included(base)
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ module ClassMethods
13
+ # Define an encrypted field
14
+ # @param name [Symbol] Field name
15
+ # @param aad_fields [Array<Symbol>] Optional fields to include in AAD
16
+ # @param kwargs [Hash] Additional field options
17
+ def encrypted_field(name, aad_fields: [], **)
18
+ require_relative 'encrypted_fields/encrypted_field_type'
19
+
20
+ field_type = EncryptedFieldType.new(name, aad_fields: aad_fields, **)
21
+ register_field_type(field_type)
22
+ end
23
+ end
24
+
25
+ Familia::Base.add_feature self, :encrypted_fields
26
+ end
27
+ end
28
+ end
@@ -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
- module Familia::Features
10
+ def self.included(base)
11
+ Familia.ld "[#{base}] Loaded #{self}"
12
+ base.extend ClassMethods
5
13
 
6
- module Expiration
7
- @default_expiration = nil
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
- module ClassMethods
18
+ base.instance_variable_set(:@default_expiration, @default_expiration) # set above
19
+ end
10
20
 
11
- attr_writer :default_expiration
21
+ # ClassMethods
22
+ #
23
+ module ClassMethods
24
+ attr_writer :default_expiration
12
25
 
13
- def default_expiration(v = nil)
14
- @default_expiration = v.to_f unless v.nil?
15
- @default_expiration || parent&.default_expiration || Familia.default_expiration
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
- end
19
-
20
- def self.included base
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
- # Optionally define default_expiration in the class to make
25
- # sure we always have an array to work with.
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
- def default_expiration=(v)
32
- @default_expiration = v.to_f
33
- end
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
- def default_expiration
36
- @default_expiration || self.class.default_expiration
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
- # Sets an expiration time for the Database data associated with this object.
40
- #
41
- # This method allows setting a Time To Live (TTL) for the data in Redis,
42
- # after which it will be automatically removed.
43
- #
44
- # @param default_expiration [Integer, nil] The Time To Live in seconds. If nil, the default
45
- # TTL will be used.
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
- # @return [Boolean] Returns true if the expiration was set successfully,
48
- # false otherwise.
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
- # @example Setting an expiration of one day
51
- # object.update_expiration(default_expiration: 86400)
116
+ # @param default_expiration [Integer, nil] Time To Live in seconds
117
+ # @return [nil] Always returns nil
52
118
  #
53
- # @note If Default expiration is set to zero, the expiration will be removed, making the
54
- # data persist indefinitely.
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
- default_expiration ||= self.default_expiration
61
-
62
- if self.class.has_relations?
63
- Familia.ld "[update_expiration] #{self.class} has relations: #{self.class.related_fields.keys}"
64
- self.class.related_fields.each do |name, definition|
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