mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 03:24:09 +00:00
Initial security fixes (#461)
* Initial sec * Update PII fields * FIX add tests * FIX safely read plaintext data on rake backfill * Update user.rb * FIX tests * encryption_ready? block * Test conditional to encryption on --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
belongs_to :user
|
||||
|
||||
# Use Rails built-in encryption for secure storage
|
||||
encrypts :display_key, deterministic: true
|
||||
# Encrypt display_key if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :display_key, deterministic: true
|
||||
end
|
||||
|
||||
# Constants
|
||||
SOURCES = [ "web", "mobile" ].freeze
|
||||
|
||||
16
app/models/concerns/encryptable.rb
Normal file
16
app/models/concerns/encryptable.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module Encryptable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app.
|
||||
# This allows encryption to be optional - if not configured, sensitive fields
|
||||
# are stored in plaintext (useful for development or legacy deployments).
|
||||
def encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,11 @@
|
||||
class EnableBankingAccount < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
include CurrencyNormalizable, Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :enable_banking_item
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
class EnableBankingItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :client_certificate, deterministic: true
|
||||
encrypts :session_id, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
class Invitation < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :inviter, class_name: "User"
|
||||
|
||||
# Encrypt sensitive fields if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :token, deterministic: true
|
||||
encrypts :email, deterministic: true, downcase: true
|
||||
end
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :role, presence: true, inclusion: { in: %w[admin member] }
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
class InviteCode < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt token if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :token, deterministic: true, downcase: true
|
||||
end
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
class << self
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
class LunchflowAccount < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
include CurrencyNormalizable, Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :lunchflow_item
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
class LunchflowItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :api_key, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
class MobileDevice < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt device_id if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :device_id, deterministic: true
|
||||
end
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
class PlaidAccount < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
encrypts :raw_investments_payload
|
||||
encrypts :raw_liabilities_payload
|
||||
end
|
||||
|
||||
belongs_to :plaid_item
|
||||
|
||||
# Legacy association via foreign key (will be removed after migration)
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
class PlaidItem < ApplicationRecord
|
||||
include Syncable, Provided
|
||||
include Syncable, Provided, Encryptable
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :access_token, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
class Session < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt user_agent if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :user_agent
|
||||
end
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :active_impersonator_session,
|
||||
-> { where(status: :in_progress) },
|
||||
class_name: "ImpersonationSession",
|
||||
optional: true
|
||||
|
||||
before_create do
|
||||
self.user_agent = Current.user_agent
|
||||
self.ip_address = Current.ip_address
|
||||
end
|
||||
before_create :capture_session_info
|
||||
|
||||
def get_preferred_tab(tab_key)
|
||||
data.dig("tab_preferences", tab_key)
|
||||
@@ -19,4 +23,13 @@ class Session < ApplicationRecord
|
||||
data["tab_preferences"][tab_key] = tab_value
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def capture_session_info
|
||||
self.user_agent = Current.user_agent
|
||||
raw_ip = Current.ip_address
|
||||
self.ip_address = raw_ip
|
||||
self.ip_address_digest = Digest::SHA256.hexdigest(raw_ip.to_s) if raw_ip.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
class SimplefinAccount < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Encrypt raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
encrypts :raw_holdings_payload
|
||||
end
|
||||
|
||||
belongs_to :simplefin_item
|
||||
|
||||
# Legacy association via foreign key (will be removed after migration)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class SimplefinItem < ApplicationRecord
|
||||
include Syncable, Provided
|
||||
include Syncable, Provided, Encryptable
|
||||
include SimplefinItem::Unlinking
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
@@ -7,18 +7,11 @@ class SimplefinItem < ApplicationRecord
|
||||
# Virtual attribute for the setup token form field
|
||||
attr_accessor :setup_token
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :access_url, deterministic: true
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_institution_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SsoProvider < ApplicationRecord
|
||||
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
|
||||
encrypts :client_secret, deterministic: false
|
||||
include Encryptable
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :client_secret, deterministic: false
|
||||
end
|
||||
|
||||
# Default enabled to true for new providers
|
||||
attribute :enabled, :boolean, default: true
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
class User < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
# Allow nil password for SSO-only users (JIT provisioning).
|
||||
# Custom validation ensures password is present for non-SSO registration.
|
||||
has_secure_password validations: false
|
||||
|
||||
# Encrypt sensitive fields if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
# MFA secrets
|
||||
encrypts :otp_secret, deterministic: true
|
||||
# Note: otp_backup_codes is a PostgreSQL array column which doesn't support
|
||||
# AR encryption. To encrypt it, a migration would be needed to change the
|
||||
# column type from array to text/jsonb.
|
||||
|
||||
# PII - emails (deterministic for lookups, downcase for case-insensitive)
|
||||
encrypts :email, deterministic: true, downcase: true
|
||||
encrypts :unconfirmed_email, deterministic: true, downcase: true
|
||||
|
||||
# PII - names (non-deterministic for maximum security)
|
||||
encrypts :first_name
|
||||
encrypts :last_name
|
||||
end
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
|
||||
has_many :sessions, dependent: :destroy
|
||||
@@ -332,7 +351,7 @@ class User < ApplicationRecord
|
||||
if (index = otp_backup_codes.index(code))
|
||||
remaining_codes = otp_backup_codes.dup
|
||||
remaining_codes.delete_at(index)
|
||||
update_column(:otp_backup_codes, remaining_codes)
|
||||
update!(otp_backup_codes: remaining_codes)
|
||||
true
|
||||
else
|
||||
false
|
||||
|
||||
15
db/migrate/20251217141218_add_hash_columns_for_security.rb
Normal file
15
db/migrate/20251217141218_add_hash_columns_for_security.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class AddHashColumnsForSecurity < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Invitations - for token hashing
|
||||
add_column :invitations, :token_digest, :string
|
||||
add_index :invitations, :token_digest, unique: true, where: "token_digest IS NOT NULL"
|
||||
|
||||
# InviteCodes - for token hashing
|
||||
add_column :invite_codes, :token_digest, :string
|
||||
add_index :invite_codes, :token_digest, unique: true, where: "token_digest IS NOT NULL"
|
||||
|
||||
# Sessions - for IP hashing
|
||||
add_column :sessions, :ip_address_digest, :string
|
||||
add_index :sessions, :ip_address_digest
|
||||
end
|
||||
end
|
||||
6
db/schema.rb
generated
6
db/schema.rb
generated
@@ -679,18 +679,22 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_23_000000) do
|
||||
t.datetime "expires_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "token_digest"
|
||||
t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true
|
||||
t.index ["email"], name: "index_invitations_on_email"
|
||||
t.index ["family_id"], name: "index_invitations_on_family_id"
|
||||
t.index ["inviter_id"], name: "index_invitations_on_inviter_id"
|
||||
t.index ["token"], name: "index_invitations_on_token", unique: true
|
||||
t.index ["token_digest"], name: "index_invitations_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)"
|
||||
end
|
||||
|
||||
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "token", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "token_digest"
|
||||
t.index ["token"], name: "index_invite_codes_on_token", unique: true
|
||||
t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)"
|
||||
end
|
||||
|
||||
create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -1103,7 +1107,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_23_000000) do
|
||||
t.datetime "subscribed_at"
|
||||
t.jsonb "prev_transaction_page_params", default: {}
|
||||
t.jsonb "data", default: {}
|
||||
t.string "ip_address_digest"
|
||||
t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
|
||||
t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest"
|
||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||
end
|
||||
|
||||
|
||||
179
lib/tasks/security_backfill.rake
Normal file
179
lib/tasks/security_backfill.rake
Normal file
@@ -0,0 +1,179 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :security do
|
||||
desc "Backfill encryption for sensitive fields (idempotent). Args: batch_size, dry_run"
|
||||
task :backfill_encryption, [ :batch_size, :dry_run ] => :environment do |_, args|
|
||||
raw_batch = args[:batch_size].presence || ENV["BATCH_SIZE"].presence || "100"
|
||||
raw_dry = args[:dry_run].presence || ENV["DRY_RUN"].presence
|
||||
|
||||
batch_size = raw_batch.to_i
|
||||
batch_size = 100 if batch_size <= 0
|
||||
|
||||
dry_run = case raw_dry.to_s.strip.downcase
|
||||
when "0", "false", "no", "n" then false
|
||||
when "1", "true", "yes", "y" then true
|
||||
else
|
||||
true # Default to dry run for safety
|
||||
end
|
||||
|
||||
# Check encryption configuration (use User model which includes Encryptable)
|
||||
unless User.encryption_ready?
|
||||
puts({
|
||||
ok: false,
|
||||
error: "encryption_not_configured",
|
||||
message: "ActiveRecord encryption is not configured. Set credentials or environment variables."
|
||||
}.to_json)
|
||||
exit 1
|
||||
end
|
||||
|
||||
results = {}
|
||||
puts "Starting security backfill (dry_run: #{dry_run}, batch_size: #{batch_size})..."
|
||||
|
||||
# User fields (MFA + PII)
|
||||
# Note: otp_backup_codes excluded - it's a PostgreSQL array column incompatible with AR encryption
|
||||
results[:users] = backfill_model(User, %i[otp_secret email unconfirmed_email first_name last_name], batch_size, dry_run)
|
||||
|
||||
# Invitation tokens and email
|
||||
results[:invitations] = backfill_model(Invitation, %i[token email], batch_size, dry_run)
|
||||
|
||||
# InviteCode tokens
|
||||
results[:invite_codes] = backfill_model(InviteCode, %i[token], batch_size, dry_run)
|
||||
|
||||
# Session user_agent (encryption) and ip_address_digest (hashing)
|
||||
results[:sessions] = backfill_sessions(batch_size, dry_run)
|
||||
|
||||
# MobileDevice device_id
|
||||
results[:mobile_devices] = backfill_model(MobileDevice, %i[device_id], batch_size, dry_run)
|
||||
|
||||
# Provider items
|
||||
results[:plaid_items] = backfill_model(PlaidItem, %i[access_token raw_payload raw_institution_payload], batch_size, dry_run)
|
||||
results[:simplefin_items] = backfill_model(SimplefinItem, %i[access_url raw_payload raw_institution_payload], batch_size, dry_run)
|
||||
results[:lunchflow_items] = backfill_model(LunchflowItem, %i[api_key raw_payload raw_institution_payload], batch_size, dry_run)
|
||||
results[:enable_banking_items] = backfill_model(EnableBankingItem, %i[client_certificate session_id raw_payload raw_institution_payload], batch_size, dry_run)
|
||||
|
||||
# Provider accounts
|
||||
results[:plaid_accounts] = backfill_model(PlaidAccount, %i[raw_payload raw_transactions_payload raw_investments_payload raw_liabilities_payload], batch_size, dry_run)
|
||||
results[:simplefin_accounts] = backfill_model(SimplefinAccount, %i[raw_payload raw_transactions_payload raw_holdings_payload], batch_size, dry_run)
|
||||
results[:lunchflow_accounts] = backfill_model(LunchflowAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run)
|
||||
results[:enable_banking_accounts] = backfill_model(EnableBankingAccount, %i[raw_payload raw_transactions_payload], batch_size, dry_run)
|
||||
|
||||
puts({
|
||||
ok: true,
|
||||
dry_run: dry_run,
|
||||
batch_size: batch_size,
|
||||
results: results
|
||||
}.to_json)
|
||||
end
|
||||
|
||||
def backfill_model(model_class, fields, batch_size, dry_run, &filter_block)
|
||||
processed = 0
|
||||
updated = 0
|
||||
failed = []
|
||||
|
||||
model_class.order(:id).in_batches(of: batch_size) do |batch|
|
||||
batch.each do |record|
|
||||
processed += 1
|
||||
|
||||
# Skip if filter block returns false
|
||||
next if block_given? && !filter_block.call(record)
|
||||
|
||||
# Check if any field has data (use safe read to handle plaintext)
|
||||
next unless fields.any? { |f| safe_read_field(record, f).present? }
|
||||
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
# Read plaintext values safely
|
||||
plaintext_values = {}
|
||||
fields.each do |field|
|
||||
value = safe_read_field(record, field)
|
||||
plaintext_values[field] = value if value.present?
|
||||
end
|
||||
|
||||
next if plaintext_values.empty?
|
||||
|
||||
# Use a temporary instance to encrypt values (avoids triggering
|
||||
# validations/callbacks that might read other encrypted fields)
|
||||
encryptor = model_class.new
|
||||
plaintext_values.each do |field, value|
|
||||
encryptor.send("#{field}=", value)
|
||||
end
|
||||
|
||||
# Extract the encrypted values from the temporary instance
|
||||
encrypted_attrs = {}
|
||||
plaintext_values.keys.each do |field|
|
||||
encrypted_attrs[field] = encryptor.read_attribute_before_type_cast(field)
|
||||
end
|
||||
|
||||
# Write directly to database, bypassing callbacks/validations
|
||||
record.update_columns(encrypted_attrs)
|
||||
updated += 1
|
||||
rescue => e
|
||||
failed << { id: record.id, error: e.class.name, message: e.message }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
processed: processed,
|
||||
updated: updated,
|
||||
failed_count: failed.size,
|
||||
failed_samples: failed.take(3)
|
||||
}
|
||||
end
|
||||
|
||||
# Safely read a field value, handling both encrypted and plaintext data.
|
||||
# When encryption is configured but the value is plaintext, the getter
|
||||
# raises ActiveRecord::Encryption::Errors::Decryption. In this case,
|
||||
# we fall back to reading the raw database value.
|
||||
def safe_read_field(record, field)
|
||||
record.send(field)
|
||||
rescue ActiveRecord::Encryption::Errors::Decryption
|
||||
record.read_attribute_before_type_cast(field)
|
||||
end
|
||||
|
||||
def backfill_sessions(batch_size, dry_run)
|
||||
processed = 0
|
||||
updated = 0
|
||||
failed = []
|
||||
|
||||
Session.order(:id).in_batches(of: batch_size) do |batch|
|
||||
batch.each do |session|
|
||||
processed += 1
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
changes = {}
|
||||
|
||||
# Re-save user_agent to trigger encryption (use safe read for plaintext)
|
||||
user_agent_value = safe_read_field(session, :user_agent)
|
||||
if user_agent_value.present?
|
||||
# Use temporary instance to encrypt
|
||||
encryptor = Session.new
|
||||
encryptor.user_agent = user_agent_value
|
||||
changes[:user_agent] = encryptor.read_attribute_before_type_cast(:user_agent)
|
||||
end
|
||||
|
||||
# Hash IP address into ip_address_digest if not already done
|
||||
if session.ip_address.present? && session.ip_address_digest.blank?
|
||||
changes[:ip_address_digest] = Digest::SHA256.hexdigest(session.ip_address.to_s)
|
||||
end
|
||||
|
||||
if changes.present?
|
||||
session.update_columns(changes)
|
||||
updated += 1
|
||||
end
|
||||
rescue => e
|
||||
failed << { id: session.id, error: e.class.name, message: e.message }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
processed: processed,
|
||||
updated: updated,
|
||||
failed_count: failed.size,
|
||||
failed_samples: failed.take(3)
|
||||
}
|
||||
end
|
||||
end
|
||||
307
test/encryption_verification_test.rb
Normal file
307
test/encryption_verification_test.rb
Normal file
@@ -0,0 +1,307 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class EncryptionVerificationTest < ActiveSupport::TestCase
|
||||
# Skip all tests in this file if encryption is not configured.
|
||||
# This allows the test suite to pass in environments without encryption keys.
|
||||
setup do
|
||||
skip "Encryption not configured" unless User.encryption_ready?
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# USER MODEL TESTS
|
||||
# ============================================================================
|
||||
|
||||
test "user email is encrypted and can be looked up" do
|
||||
user = User.create!(
|
||||
email: "encryption-test@example.com",
|
||||
password: "password123",
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
|
||||
# Verify we can find by email (deterministic encryption)
|
||||
found = User.find_by(email: "encryption-test@example.com")
|
||||
assert_equal user.id, found.id
|
||||
|
||||
# Verify case-insensitive lookup works
|
||||
found_upper = User.find_by(email: "ENCRYPTION-TEST@EXAMPLE.COM")
|
||||
assert_equal user.id, found_upper.id
|
||||
|
||||
# Clean up
|
||||
user.destroy
|
||||
end
|
||||
|
||||
test "user email uniqueness validation works with encryption" do
|
||||
user1 = User.create!(
|
||||
email: "unique-test@example.com",
|
||||
password: "password123",
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
|
||||
# Should fail uniqueness
|
||||
user2 = User.new(
|
||||
email: "unique-test@example.com",
|
||||
password: "password123",
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
assert_not user2.valid?
|
||||
assert user2.errors[:email].any?
|
||||
|
||||
user1.destroy
|
||||
end
|
||||
|
||||
test "user names are encrypted and retrievable" do
|
||||
user = users(:family_admin)
|
||||
original_first = user.first_name
|
||||
original_last = user.last_name
|
||||
|
||||
# Update names
|
||||
user.update!(first_name: "EncryptedFirst", last_name: "EncryptedLast")
|
||||
user.reload
|
||||
|
||||
assert_equal "EncryptedFirst", user.first_name
|
||||
assert_equal "EncryptedLast", user.last_name
|
||||
|
||||
# Restore
|
||||
user.update!(first_name: original_first, last_name: original_last)
|
||||
end
|
||||
|
||||
test "user MFA otp_secret is encrypted" do
|
||||
user = users(:family_admin)
|
||||
|
||||
# Setup MFA
|
||||
user.setup_mfa!
|
||||
assert user.otp_secret.present?
|
||||
|
||||
# Reload and verify we can still read it
|
||||
user.reload
|
||||
assert user.otp_secret.present?
|
||||
|
||||
# Verify provisioning URI works
|
||||
assert user.provisioning_uri.present?
|
||||
|
||||
# Clean up
|
||||
user.disable_mfa!
|
||||
end
|
||||
|
||||
test "user unconfirmed_email is encrypted" do
|
||||
user = users(:family_admin)
|
||||
original_email = user.email
|
||||
|
||||
# Set unconfirmed email
|
||||
user.update!(unconfirmed_email: "new-email@example.com")
|
||||
user.reload
|
||||
|
||||
assert_equal "new-email@example.com", user.unconfirmed_email
|
||||
|
||||
# Clean up
|
||||
user.update!(unconfirmed_email: nil)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# INVITATION MODEL TESTS
|
||||
# ============================================================================
|
||||
|
||||
test "invitation token is encrypted and lookups work" do
|
||||
invitation = Invitation.create!(
|
||||
email: "invite-test@example.com",
|
||||
role: "member",
|
||||
inviter: users(:family_admin),
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
|
||||
# Token should be present
|
||||
assert invitation.token.present?
|
||||
token_value = invitation.token
|
||||
|
||||
# Should be able to find by token
|
||||
found = Invitation.find_by(token: token_value)
|
||||
assert_equal invitation.id, found.id
|
||||
|
||||
invitation.destroy
|
||||
end
|
||||
|
||||
test "invitation email is encrypted and scoped uniqueness works" do
|
||||
invitation1 = Invitation.create!(
|
||||
email: "scoped-invite@example.com",
|
||||
role: "member",
|
||||
inviter: users(:family_admin),
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
|
||||
# Same email, same family should fail
|
||||
invitation2 = Invitation.new(
|
||||
email: "scoped-invite@example.com",
|
||||
role: "member",
|
||||
inviter: users(:family_admin),
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
assert_not invitation2.valid?
|
||||
|
||||
invitation1.destroy
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# INVITE CODE MODEL TESTS
|
||||
# ============================================================================
|
||||
|
||||
test "invite code token is encrypted and claim works" do
|
||||
token = InviteCode.generate!
|
||||
assert token.present?
|
||||
|
||||
# Should be able to claim
|
||||
result = InviteCode.claim!(token)
|
||||
assert result
|
||||
|
||||
# Should not be able to claim again (destroyed)
|
||||
result2 = InviteCode.claim!(token)
|
||||
assert_nil result2
|
||||
end
|
||||
|
||||
test "invite code case-insensitive lookup works" do
|
||||
invite_code = InviteCode.create!
|
||||
token = invite_code.token
|
||||
|
||||
# Should find with lowercase
|
||||
found = InviteCode.find_by(token: token.downcase)
|
||||
assert_equal invite_code.id, found.id
|
||||
|
||||
invite_code.destroy
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# SESSION MODEL TESTS
|
||||
# ============================================================================
|
||||
|
||||
test "session user_agent is encrypted" do
|
||||
Current.user_agent = "Mozilla/5.0 Test Browser"
|
||||
Current.ip_address = "192.168.1.100"
|
||||
|
||||
begin
|
||||
session = Session.create!(user: users(:family_admin))
|
||||
|
||||
assert_equal "Mozilla/5.0 Test Browser", session.user_agent
|
||||
assert session.ip_address_digest.present?
|
||||
|
||||
# Reload and verify
|
||||
session.reload
|
||||
assert_equal "Mozilla/5.0 Test Browser", session.user_agent
|
||||
|
||||
# Verify IP hash is consistent
|
||||
expected_hash = Digest::SHA256.hexdigest("192.168.1.100")
|
||||
assert_equal expected_hash, session.ip_address_digest
|
||||
|
||||
session.destroy
|
||||
ensure
|
||||
Current.user_agent = nil
|
||||
Current.ip_address = nil
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# MOBILE DEVICE MODEL TESTS
|
||||
# ============================================================================
|
||||
|
||||
test "mobile device device_id is encrypted and uniqueness works" do
|
||||
device = MobileDevice.create!(
|
||||
user: users(:family_admin),
|
||||
device_id: "test-device-12345",
|
||||
device_name: "Test iPhone",
|
||||
device_type: "ios"
|
||||
)
|
||||
|
||||
# Should be able to find by device_id
|
||||
found = MobileDevice.find_by(device_id: "test-device-12345", user: users(:family_admin))
|
||||
assert_equal device.id, found.id
|
||||
|
||||
# Same device_id for same user should fail
|
||||
device2 = MobileDevice.new(
|
||||
user: users(:family_admin),
|
||||
device_id: "test-device-12345",
|
||||
device_name: "Another iPhone",
|
||||
device_type: "ios"
|
||||
)
|
||||
assert_not device2.valid?
|
||||
|
||||
# Same device_id for different user should work
|
||||
device3 = MobileDevice.new(
|
||||
user: users(:family_member),
|
||||
device_id: "test-device-12345",
|
||||
device_name: "Their iPhone",
|
||||
device_type: "ios"
|
||||
)
|
||||
assert device3.valid?
|
||||
|
||||
device.destroy
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# PROVIDER ITEM TESTS (if fixtures exist)
|
||||
# ============================================================================
|
||||
|
||||
test "lunchflow item credentials and payloads are encrypted" do
|
||||
skip "No lunchflow items in fixtures" unless LunchflowItem.any?
|
||||
|
||||
item = LunchflowItem.first
|
||||
original_payload = item.raw_payload
|
||||
|
||||
# Should be able to read
|
||||
assert item.api_key.present? || item.raw_payload.present?
|
||||
|
||||
# Update payload
|
||||
item.update!(raw_payload: { test: "data" })
|
||||
item.reload
|
||||
|
||||
assert_equal({ "test" => "data" }, item.raw_payload)
|
||||
|
||||
# Restore
|
||||
item.update!(raw_payload: original_payload)
|
||||
end
|
||||
|
||||
test "lunchflow account payloads are encrypted" do
|
||||
skip "No lunchflow accounts in fixtures" unless LunchflowAccount.any?
|
||||
|
||||
account = LunchflowAccount.first
|
||||
original_payload = account.raw_payload
|
||||
|
||||
# Should be able to read encrypted fields without error
|
||||
account.reload
|
||||
assert_nothing_raised { account.raw_payload }
|
||||
assert_nothing_raised { account.raw_transactions_payload }
|
||||
|
||||
# Update and verify
|
||||
account.update!(raw_payload: { account_test: "value" })
|
||||
account.reload
|
||||
|
||||
assert_equal({ "account_test" => "value" }, account.raw_payload)
|
||||
|
||||
# Restore
|
||||
account.update!(raw_payload: original_payload)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE VERIFICATION TESTS
|
||||
# ============================================================================
|
||||
|
||||
test "encrypted fields are not stored as plaintext in database" do
|
||||
user = User.create!(
|
||||
email: "plaintext-check@example.com",
|
||||
password: "password123",
|
||||
first_name: "PlaintextFirst",
|
||||
last_name: "PlaintextLast",
|
||||
family: families(:dylan_family)
|
||||
)
|
||||
|
||||
# Query raw database value
|
||||
raw_email = ActiveRecord::Base.connection.select_value(
|
||||
User.where(id: user.id).select(:email).to_sql
|
||||
)
|
||||
|
||||
# Should NOT be plaintext (should be encrypted blob or different)
|
||||
assert_not_equal "plaintext-check@example.com", raw_email,
|
||||
"Email should be encrypted in database, not stored as plaintext"
|
||||
|
||||
user.destroy
|
||||
end
|
||||
end
|
||||
@@ -80,6 +80,8 @@ class SsoProviderTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "encrypts client_secret" do
|
||||
skip "Encryption not configured" unless SsoProvider.encryption_ready?
|
||||
|
||||
provider = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "encrypted_test",
|
||||
|
||||
Reference in New Issue
Block a user