Files
sure/app/models/invitation.rb
dripsmvcp ab52b2b144 fix(family-sharing): prevent silent data loss when rehoming or removing users (#1896)
* fix(family-sharing): prevent silent data loss when rehoming or removing users

Fixes #1689.

Two destructive paths could strand a pre-existing user's family and accounts:

1. Invitation#accept_for unconditionally overwrote user.family_id, orphaning
   the prior family + its accounts with no user able to reach them.
2. Settings::ProfilesController#destroy then called @user.destroy when an admin
   removed the rehomed member, destroying the only login path back to the
   now-orphaned data.

Add hard-block guards on both paths. accept_for refuses when the invitee
already belongs to a family with accounts; ProfilesController#destroy refuses
when the member owns accounts in another family (legacy state from the old
flow). InvitationsController#create surfaces a specific, actionable flash so
the admin understands why the auto-accept was refused.

No automatic recovery of already-orphaned data — that needs a separate
one-shot script per dosubot's analysis on the issue.

* fix(family-sharing): scope invite orphan-guard to invitee-owned accounts (#1896 review)

Codex flagged (P1) and the maintainer review independently raised that
would_orphan_existing_family? keyed off user.family.accounts.exists? —
any account in the invitee's current family — which wrongly blocked a
non-owner member from leaving a multi-user household.

Rename to would_orphan_owned_accounts? and key off
user.owned_accounts.where.not(family_id: family_id), making the invite
guard symmetric with the destroy-path guard in
Settings::ProfilesController. A member who owns no accounts now orphans
nothing by moving and is free to accept the invitation; an owner is
still blocked.

Add a regression test for the non-owner case and update the existing
tests to give the invitee explicit account ownership.

* Remove extra comments per project conventions

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-27 23:25:46 +02:00

118 lines
3.5 KiB
Ruby

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 guest] }
validates :token, presence: true, uniqueness: true
validate :no_duplicate_pending_invitation_in_family
validate :inviter_is_admin
validate :no_other_pending_invitation, on: :create
before_validation :normalize_email
before_validation :generate_token, on: :create
before_create :set_expiration
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
def pending?
accepted_at.nil? && expires_at > Time.current
end
def accept_for(user)
return false if user.blank?
return false unless pending?
return false unless emails_match?(user)
return false if would_orphan_owned_accounts?(user)
transaction do
user.update!(family_id: family_id, role: role.to_s)
update!(accepted_at: Time.current)
auto_share_existing_accounts(user) if family.share_all_by_default?
end
true
end
def would_orphan_owned_accounts?(user)
return false if user.blank?
return false if user.family_id.blank?
return false if user.family_id == family_id
user.owned_accounts.where.not(family_id: family_id).exists?
end
private
def emails_match?(user)
inv_email = email.to_s.strip.downcase
usr_email = user.email.to_s.strip.downcase
inv_email.present? && usr_email.present? && inv_email == usr_email
end
def generate_token
loop do
self.token = SecureRandom.hex(32)
break unless self.class.exists?(token: token)
end
end
def set_expiration
self.expires_at = 3.days.from_now
end
def normalize_email
self.email = email.to_s.strip.downcase if email.present?
end
def no_other_pending_invitation
return if email.blank?
existing = if self.class.encryption_ready?
self.class.pending.where(email: email).where.not(family_id: family_id).exists?
else
self.class.pending.where("LOWER(email) = ?", email.downcase).where.not(family_id: family_id).exists?
end
if existing
errors.add(:email, "already has a pending invitation from another family")
end
end
def no_duplicate_pending_invitation_in_family
return if email.blank?
scope = self.class.pending.where(family_id: family_id)
scope = scope.where.not(id: id) if persisted?
exists = if self.class.encryption_ready?
scope.where(email: email).exists?
else
scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists?
end
errors.add(:email, "has already been invited to this family") if exists
end
def inviter_is_admin
inviter.admin?
end
def auto_share_existing_accounts(user)
records = family.accounts.where.not(owner_id: user.id).pluck(:id).map do |account_id|
{ account_id: account_id, user_id: user.id, permission: "read_write",
include_in_finances: true, created_at: Time.current, updated_at: Time.current }
end
AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any?
end
end