ff1 1.1.0 → 1.2.3
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/CHANGELOG.md +1 -1
- data/README.md +214 -1
- data/examples/activerecord_usage.rb +144 -0
- data/lib/ff1/active_record/base.rb +80 -0
- data/lib/ff1/active_record/encryption.rb +167 -0
- data/lib/ff1/active_record/scopes.rb +206 -0
- data/lib/ff1/active_record/soft_delete.rb +192 -0
- data/lib/ff1/active_record.rb +125 -0
- data/lib/ff1/version.rb +1 -1
- data/lib/ff1.rb +20 -0
- data/lib/generators/ff1/install_generator.rb +101 -0
- data/lib/generators/ff1/templates/initializer.rb +55 -0
- data/lib/generators/ff1/templates/migration.rb.erb +15 -0
- data/spec/ff1_active_record_spec.rb +382 -0
- data/spec/generators/ff1/install_generator_spec.rb +250 -0
- metadata +70 -3
@@ -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
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
|