ff1 1.2.0 → 1.2.4

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.
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FF1
4
+ module ActiveRecord
5
+ # Query scopes for FF1-enabled models
6
+ module Scopes
7
+ extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
8
+
9
+ included do
10
+ # Define scopes for querying active vs soft-deleted records
11
+
12
+ # Scope for active (not soft-deleted) records
13
+ scope :ff1_active, -> do
14
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
15
+
16
+ if column_names.include?(ff1_deleted_column.to_s)
17
+ where(ff1_deleted_column => [nil, false])
18
+ else
19
+ all
20
+ end
21
+ end
22
+
23
+ # Scope for soft-deleted records
24
+ scope :ff1_deleted, -> do
25
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
26
+
27
+ if column_names.include?(ff1_deleted_column.to_s)
28
+ where(ff1_deleted_column => true)
29
+ else
30
+ none
31
+ end
32
+ end
33
+
34
+ # Scope for all records (active + soft-deleted)
35
+ scope :ff1_all, -> { unscoped }
36
+
37
+ # Scope for recently deleted records
38
+ scope :ff1_recently_deleted, ->(within: 7.days) do
39
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
40
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
41
+
42
+ if column_names.include?(deleted_at_column.to_s) && column_names.include?(ff1_deleted_column.to_s)
43
+ where(ff1_deleted_column => true)
44
+ .where(deleted_at_column => within.ago..Time.current)
45
+ else
46
+ none
47
+ end
48
+ end
49
+
50
+ # Scope for old deleted records (candidates for purging)
51
+ scope :ff1_old_deleted, ->(older_than: 30.days) do
52
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
53
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
54
+
55
+ if column_names.include?(deleted_at_column.to_s) && column_names.include?(ff1_deleted_column.to_s)
56
+ where(ff1_deleted_column => true)
57
+ .where("#{deleted_at_column} < ?", older_than.ago)
58
+ else
59
+ none
60
+ end
61
+ end
62
+
63
+ # Note: default_scope commented out as it can interfere with tests and normal queries
64
+ # To enable default hiding of deleted records, uncomment the lines below:
65
+ #
66
+ # default_scope -> do
67
+ # if FF1::ActiveRecord.configuration.respond_to?(:hide_deleted_by_default) &&
68
+ # FF1::ActiveRecord.configuration.hide_deleted_by_default == false
69
+ # all
70
+ # else
71
+ # ff1_active
72
+ # end
73
+ # end
74
+ end
75
+
76
+ module ClassMethods
77
+ # Count active records
78
+ def ff1_active_count
79
+ ff1_active.count
80
+ end
81
+
82
+ # Count soft-deleted records
83
+ def ff1_deleted_count
84
+ ff1_deleted.count
85
+ end
86
+
87
+ # Find including soft-deleted records
88
+ def ff1_find(id)
89
+ ff1_all.find(id)
90
+ end
91
+
92
+ # Find active record by ID
93
+ def ff1_find_active(id)
94
+ ff1_active.find(id)
95
+ end
96
+
97
+ # Find soft-deleted record by ID
98
+ def ff1_find_deleted(id)
99
+ ff1_deleted.find(id)
100
+ end
101
+
102
+ # Statistics about FF1 usage in this model
103
+ def ff1_stats
104
+ {
105
+ total_records: ff1_all.count,
106
+ active_records: ff1_active_count,
107
+ deleted_records: ff1_deleted_count,
108
+ encrypted_columns: ff1_encrypted_columns.keys,
109
+ deletion_rate: ff1_all.count > 0 ? (ff1_deleted_count.to_f / ff1_all.count * 100).round(2) : 0
110
+ }
111
+ end
112
+
113
+ # Batch process records with FF1 encryption
114
+ #
115
+ # @param batch_size [Integer] Number of records to process at once
116
+ # @param scope [Symbol] Scope to use (:active, :deleted, :all)
117
+ # @yield [record] Block to execute for each record
118
+ #
119
+ def ff1_find_each(batch_size: 1000, scope: :active)
120
+ target_scope = case scope
121
+ when :active then ff1_active
122
+ when :deleted then ff1_deleted
123
+ when :all then ff1_all
124
+ else ff1_active
125
+ end
126
+
127
+ target_scope.find_each(batch_size: batch_size) do |record|
128
+ yield(record)
129
+ end
130
+ end
131
+
132
+ # Batch encrypt existing data in database
133
+ #
134
+ # This method is useful for migrating existing data to use FF1 encryption.
135
+ # It processes records in batches to avoid memory issues.
136
+ #
137
+ # @param columns [Array<Symbol>] Columns to encrypt (nil for all configured columns)
138
+ # @param batch_size [Integer] Number of records to process at once
139
+ # @param mode [Symbol] Encryption mode to use
140
+ #
141
+ def ff1_encrypt_existing_data!(columns: nil, batch_size: 100, mode: FF1::Modes::REVERSIBLE)
142
+ columns ||= ff1_encrypted_columns.keys
143
+ processed = 0
144
+ errors = 0
145
+
146
+ transaction do
147
+ ff1_active.find_each(batch_size: batch_size) do |record|
148
+ begin
149
+ columns.each do |column|
150
+ current_value = record.read_attribute(column)
151
+ next if current_value.nil? || current_value == ''
152
+
153
+ # Only encrypt if not already encrypted (basic heuristic)
154
+ next if already_encrypted?(current_value, column)
155
+
156
+ encrypted_value = record.encrypt_attribute_value(column, current_value, mode)
157
+ record.write_attribute(column, encrypted_value) if encrypted_value
158
+ end
159
+
160
+ record.save!(validate: false)
161
+ processed += 1
162
+ rescue => e
163
+ Rails.logger.error "Failed to encrypt #{name}##{record.id}: #{e.message}" if defined?(Rails) && Rails.respond_to?(:logger)
164
+ errors += 1
165
+ end
166
+ end
167
+ end
168
+
169
+ Rails.logger.info "FF1 batch encryption complete: #{processed} processed, #{errors} errors" if defined?(Rails) && Rails.respond_to?(:logger)
170
+ { processed: processed, errors: errors }
171
+ end
172
+
173
+ private
174
+
175
+ # Basic heuristic to check if a value is already encrypted
176
+ def already_encrypted?(value, column)
177
+ return false unless value.is_a?(String)
178
+
179
+ # For text columns, check if it looks like base64
180
+ if text_column_type?(column) && value.match?(/\A[A-Za-z0-9+\/]+=*\z/) && value.length > 10
181
+ return true
182
+ end
183
+
184
+ # For numeric columns, check if it has the expected format
185
+ if numeric_column_type?(column) && value.match?(/\A\d+\z/) && value.length >= 2
186
+ # This is a weak heuristic - in practice you might want to maintain
187
+ # a migration flag or check against known unencrypted patterns
188
+ false
189
+ else
190
+ false
191
+ end
192
+ end
193
+
194
+ def text_column_type?(column)
195
+ column_type = columns_hash[column.to_s]&.type
196
+ [:string, :text].include?(column_type)
197
+ end
198
+
199
+ def numeric_column_type?(column)
200
+ column_type = columns_hash[column.to_s]&.type
201
+ [:integer, :bigint, :decimal].include?(column_type)
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FF1
4
+ module ActiveRecord
5
+ # GDPR-compliant soft delete with irreversible encryption
6
+ module SoftDelete
7
+ extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
8
+
9
+ included do
10
+ # Override destroy to perform soft delete with encryption
11
+ alias_method :hard_destroy, :destroy
12
+ end
13
+
14
+ # Soft delete with irreversible encryption for GDPR compliance
15
+ #
16
+ # Instead of actually deleting the record, this method:
17
+ # 1. Encrypts all configured columns with irreversible encryption
18
+ # 2. Sets deleted_at timestamp
19
+ # 3. Sets ff1_deleted flag to true
20
+ # 4. Saves the record with encrypted sensitive data
21
+ #
22
+ # This ensures compliance with GDPR "right to be forgotten" while
23
+ # maintaining referential integrity and audit trails.
24
+ #
25
+ def destroy
26
+ return hard_destroy unless should_ff1_soft_delete?
27
+
28
+ run_callbacks :destroy do
29
+ ff1_soft_delete
30
+ end
31
+ end
32
+
33
+ # Perform the actual soft delete with irreversible encryption
34
+ def ff1_soft_delete
35
+ return false if ff1_deleted?
36
+
37
+ transaction do
38
+ # First, irreversibly encrypt all configured columns
39
+ irreversibly_encrypt_all_columns
40
+
41
+ # Set deletion metadata
42
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
43
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
44
+
45
+ if respond_to?("#{deleted_at_column}=")
46
+ public_send("#{deleted_at_column}=", Time.current)
47
+ end
48
+
49
+ if respond_to?("#{ff1_deleted_column}=")
50
+ public_send("#{ff1_deleted_column}=", true)
51
+ end
52
+
53
+ # Save with validation skips to ensure deletion succeeds
54
+ save!(validate: false)
55
+
56
+ # Clear any cached decrypted values
57
+ clear_ff1_decrypted_cache
58
+
59
+ # Freeze the object to prevent further modifications
60
+ freeze
61
+ end
62
+
63
+ true
64
+ rescue => e
65
+ Rails.logger.error "FF1 soft delete failed for #{self.class.name}##{id}: #{e.message}" if defined?(Rails) && Rails.respond_to?(:logger)
66
+ false
67
+ end
68
+
69
+ # Restore a soft-deleted record (if possible)
70
+ #
71
+ # Note: This only restores the deletion flags and timestamp.
72
+ # The irreversibly encrypted data cannot be recovered.
73
+ #
74
+ def ff1_restore
75
+ return false unless ff1_deleted?
76
+
77
+ # If object is frozen (after soft delete), we need to reload it first
78
+ reload if frozen?
79
+
80
+ transaction do
81
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
82
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
83
+
84
+ if respond_to?("#{deleted_at_column}=")
85
+ public_send("#{deleted_at_column}=", nil)
86
+ end
87
+
88
+ if respond_to?("#{ff1_deleted_column}=")
89
+ public_send("#{ff1_deleted_column}=", false)
90
+ end
91
+
92
+ save!(validate: false)
93
+ end
94
+
95
+ true
96
+ rescue => e
97
+ Rails.logger.error "FF1 restore failed for #{self.class.name}##{id}: #{e.message}" if defined?(Rails) && Rails.respond_to?(:logger)
98
+ false
99
+ end
100
+
101
+ # Check if record should be truly destroyed (hard delete)
102
+ def ff1_hard_destroy!
103
+ # Call the original ActiveRecord destroy method directly
104
+ # We need to bypass our soft delete logic completely
105
+
106
+ run_callbacks :destroy do
107
+ # Directly delete from database without soft delete logic
108
+ self.class.delete(id)
109
+ end
110
+
111
+ # Freeze the object as per normal ActiveRecord destroy behavior
112
+ freeze
113
+ end
114
+
115
+ # Get the timestamp when the record was soft deleted
116
+ def ff1_deleted_at
117
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
118
+ respond_to?(deleted_at_column) ? public_send(deleted_at_column) : nil
119
+ end
120
+
121
+ private
122
+
123
+ # Determine if this record should use FF1 soft delete
124
+ def should_ff1_soft_delete?
125
+ # Only soft delete if:
126
+ # 1. Model has encrypted columns configured
127
+ # 2. Model has the required deletion tracking columns
128
+ return false if ff1_config.empty?
129
+
130
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
131
+ ff1_deleted_column = FF1::ActiveRecord.configuration.ff1_deleted_column
132
+
133
+ respond_to?("#{deleted_at_column}=") && respond_to?("#{ff1_deleted_column}=")
134
+ end
135
+
136
+ # Irreversibly encrypt all configured columns
137
+ def irreversibly_encrypt_all_columns
138
+ ff1_config.each do |column, _config|
139
+ current_value = read_attribute(column)
140
+ next if current_value.nil? || current_value == ''
141
+
142
+ # Always use irreversible mode for soft delete
143
+ encrypted_value = encrypt_attribute_value(column, current_value, FF1::Modes::IRREVERSIBLE)
144
+ write_attribute(column, encrypted_value) if encrypted_value
145
+
146
+ Rails.logger.info "FF1 irreversibly encrypted #{self.class.name}##{column} during soft delete" if defined?(Rails) && Rails.respond_to?(:logger)
147
+ end
148
+ end
149
+
150
+ # Clear cached decrypted values
151
+ def clear_ff1_decrypted_cache
152
+ ff1_config.keys.each do |column|
153
+ decrypted_var = "@ff1_decrypted_#{column}"
154
+ remove_instance_variable(decrypted_var) if instance_variable_defined?(decrypted_var)
155
+ end
156
+ end
157
+
158
+ module ClassMethods
159
+ # Bulk soft delete with irreversible encryption
160
+ #
161
+ # @param conditions [Hash] Conditions for records to soft delete
162
+ # @return [Integer] Number of records soft deleted
163
+ #
164
+ def ff1_destroy_all(conditions = {})
165
+ records = where(conditions).ff1_active
166
+ count = 0
167
+
168
+ records.find_each do |record|
169
+ count += 1 if record.destroy
170
+ end
171
+
172
+ count
173
+ end
174
+
175
+ # Permanently remove soft-deleted records from database
176
+ #
177
+ # @param older_than [ActiveSupport::Duration] Only purge records deleted longer ago than this
178
+ # @return [Integer] Number of records permanently deleted
179
+ #
180
+ def ff1_purge_deleted(older_than: 30.days)
181
+ deleted_at_column = FF1::ActiveRecord.configuration.deleted_at_column
182
+
183
+ conditions = {}
184
+ conditions[FF1::ActiveRecord.configuration.ff1_deleted_column] = true
185
+ conditions[deleted_at_column] = ...older_than.ago if older_than
186
+
187
+ where(conditions).delete_all
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'active_record/base'
4
+ require_relative 'active_record/encryption'
5
+ require_relative 'active_record/soft_delete'
6
+ require_relative 'active_record/scopes'
7
+
8
+ module FF1
9
+ # ActiveRecord integration for FF1 Format Preserving Encryption
10
+ #
11
+ # Provides column-level encryption configuration, automatic encryption/decryption,
12
+ # and GDPR-compliant soft delete functionality for Rails models.
13
+ #
14
+ # @example Usage
15
+ # class User < ApplicationRecord
16
+ # include FF1::ActiveRecord
17
+ #
18
+ # ff1_encrypt :email, :phone, mode: :reversible
19
+ # ff1_encrypt :ssn, mode: :irreversible
20
+ # end
21
+ #
22
+ module ActiveRecord
23
+ def self.included(base)
24
+ base.extend ClassMethods
25
+ base.include InstanceMethods
26
+ base.include Base
27
+ base.include Encryption
28
+ base.include SoftDelete
29
+ base.include Scopes
30
+ end
31
+
32
+ # Configuration for FF1 ActiveRecord integration
33
+ class Configuration
34
+ attr_accessor :global_key, :default_mode, :deleted_at_column, :ff1_deleted_column
35
+
36
+ def initialize
37
+ @global_key = nil
38
+ @default_mode = FF1::Modes::REVERSIBLE
39
+ @deleted_at_column = :deleted_at
40
+ @ff1_deleted_column = :ff1_deleted
41
+ end
42
+ end
43
+
44
+ # Global configuration accessor
45
+ def self.configuration
46
+ @configuration ||= Configuration.new
47
+ end
48
+
49
+ def self.configure
50
+ yield(configuration)
51
+ end
52
+
53
+ module ClassMethods
54
+ # Configure columns for FF1 encryption
55
+ #
56
+ # @param columns [Array<Symbol>] Column names to encrypt
57
+ # @param mode [Symbol] Encryption mode (:reversible or :irreversible)
58
+ # @param key [String] Encryption key (optional, uses global key if not provided)
59
+ # @param radix [Integer] Radix for encryption (default: 10 for numeric, 256 for text)
60
+ #
61
+ def ff1_encrypt(*columns, mode: FF1::Modes::REVERSIBLE, key: nil, radix: nil)
62
+ options = columns.extract_options!
63
+ mode = options[:mode] || mode
64
+ key = options[:key] || key
65
+ radix = options[:radix] || radix
66
+
67
+ columns.each do |column|
68
+ ff1_encrypted_columns[column] = {
69
+ mode: mode,
70
+ key: key,
71
+ radix: radix
72
+ }
73
+ end
74
+
75
+ # Define getter and setter methods for encrypted columns
76
+ columns.each do |column|
77
+ define_encrypted_attribute_methods(column)
78
+ end
79
+ end
80
+
81
+ # Get hash of encrypted columns and their configurations
82
+ def ff1_encrypted_columns
83
+ @ff1_encrypted_columns ||= {}
84
+ end
85
+
86
+ # Check if a column is encrypted
87
+ def ff1_encrypted_column?(column)
88
+ ff1_encrypted_columns.key?(column.to_sym)
89
+ end
90
+
91
+ private
92
+
93
+ # Define attribute methods for encrypted columns
94
+ def define_encrypted_attribute_methods(column)
95
+ # Override the attribute reader
96
+ define_method(column) do
97
+ decrypt_attribute(column)
98
+ end
99
+
100
+ # Override the attribute writer
101
+ define_method("#{column}=") do |value|
102
+ set_encrypted_attribute(column, value)
103
+ end
104
+ end
105
+ end
106
+
107
+ module InstanceMethods
108
+ # Get the encryption configuration for this model
109
+ def ff1_config
110
+ @ff1_config ||= self.class.ff1_encrypted_columns
111
+ end
112
+
113
+ # Check if this record has been soft deleted with FF1
114
+ def ff1_deleted?
115
+ respond_to?(FF1::ActiveRecord.configuration.ff1_deleted_column) &&
116
+ public_send(FF1::ActiveRecord.configuration.ff1_deleted_column) == true
117
+ end
118
+
119
+ # Check if this record is active (not soft deleted)
120
+ def ff1_active?
121
+ !ff1_deleted?
122
+ end
123
+ end
124
+ end
125
+ end
data/lib/ff1/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module FF1
4
4
  # Current version of the FF1 gem
5
- VERSION = '1.2.0'
5
+ VERSION = '1.2.4'
6
6
  end
data/lib/ff1.rb CHANGED
@@ -4,6 +4,26 @@ require_relative 'ff1/version'
4
4
  require_relative 'ff1/modes'
5
5
  require_relative 'ff1/cipher'
6
6
 
7
+ # Auto-load ActiveRecord integration when ActiveRecord is available
8
+ module FF1
9
+ def self.load_active_record_integration!
10
+ return if defined?(@@active_record_loaded) && @@active_record_loaded
11
+
12
+ begin
13
+ require_relative 'ff1/active_record'
14
+ @@active_record_loaded = true
15
+ rescue LoadError => e
16
+ warn "FF1::ActiveRecord integration failed to load: #{e.message}" if $VERBOSE
17
+ @@active_record_loaded = false
18
+ end
19
+ end
20
+
21
+ # Auto-load when ActiveRecord is detected
22
+ if defined?(::ActiveRecord)
23
+ load_active_record_integration!
24
+ end
25
+ end
26
+
7
27
  # FF1 Format Preserving Encryption
8
28
  #
9
29
  # A Ruby implementation of NIST SP 800-38G FF1 algorithm with dual-mode operation.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ff1
4
+ module Generators
5
+ # Rails generator for FF1 ActiveRecord integration
6
+ #
7
+ # Generates migration to add required columns for FF1 soft delete functionality
8
+ #
9
+ # Usage:
10
+ # rails generate ff1:install
11
+ # rails generate ff1:install User
12
+ # rails generate ff1:install User Post Comment
13
+ #
14
+ class InstallGenerator < Rails::Generators::Base
15
+ include Rails::Generators::Migration
16
+ include Rails::Generators::Actions
17
+ source_root File.expand_path('templates', __dir__)
18
+
19
+ argument :models, type: :array, default: [], banner: "model1 model2"
20
+
21
+ class_option :deleted_at_column, type: :string, default: 'deleted_at',
22
+ desc: 'Name of the deleted_at timestamp column'
23
+
24
+ class_option :ff1_deleted_column, type: :string, default: 'ff1_deleted',
25
+ desc: 'Name of the FF1 deleted boolean column'
26
+
27
+ class_option :add_indexes, type: :boolean, default: true,
28
+ desc: 'Add database indexes for performance'
29
+
30
+ # Required for Rails::Generators::Migration
31
+ def self.next_migration_number(path)
32
+ Time.now.strftime("%Y%m%d%H%M%S")
33
+ end
34
+
35
+ def generate_migration
36
+ if models.empty?
37
+ say "Generating FF1 configuration initializer..."
38
+ generate_initializer
39
+ else
40
+ models.each do |model_name|
41
+ generate_model_migration(model_name)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def generate_initializer
49
+ template 'initializer.rb', 'config/initializers/ff1.rb'
50
+ say "FF1 initializer created at config/initializers/ff1.rb", :green
51
+ say ""
52
+ say "Next steps:", :blue
53
+ say "1. Set your encryption key in the initializer"
54
+ say "2. Run: rails generate ff1:install ModelName for each model you want to enable"
55
+ say "3. Run: rails db:migrate"
56
+ end
57
+
58
+ def generate_model_migration(model_name)
59
+ model_class_name = model_name.camelize
60
+ table_name = model_name.underscore.pluralize
61
+ migration_name = "add_ff1_columns_to_#{table_name}"
62
+ migration_class_name = "AddFf1ColumnsTo#{model_class_name.pluralize}"
63
+
64
+ # Check if model exists
65
+ begin
66
+ model_class_name.constantize
67
+ rescue NameError
68
+ say "Warning: Model #{model_class_name} not found. Creating migration anyway.", :yellow
69
+ end
70
+
71
+ # Generate migration file
72
+ migration_template 'migration.rb.erb',
73
+ "db/migrate/#{timestamp}_#{migration_name}.rb",
74
+ migration_class: migration_class_name,
75
+ table_name: table_name,
76
+ deleted_at_column: deleted_at_column,
77
+ ff1_deleted_column: ff1_deleted_column,
78
+ add_indexes: add_indexes?
79
+
80
+ say "Migration created for #{model_class_name}", :green
81
+ say "Run 'rails db:migrate' to apply the changes", :blue
82
+ end
83
+
84
+ def timestamp
85
+ Time.now.strftime("%Y%m%d%H%M%S")
86
+ end
87
+
88
+ def deleted_at_column
89
+ options['deleted_at_column']
90
+ end
91
+
92
+ def ff1_deleted_column
93
+ options['ff1_deleted_column']
94
+ end
95
+
96
+ def add_indexes?
97
+ options['add_indexes']
98
+ end
99
+ end
100
+ end
101
+ end