Files
fizzy/config/initializers/active_storage_no_reuse.rb
2026-01-12 10:30:18 -08:00

68 lines
3.0 KiB
Ruby

# Enforce storage ledger integrity by preventing blob reuse in tracked contexts.
#
# Two invariants:
# 1. Account match: blob.account_id == record.account_id (multi-tenant safety)
# 2. No reuse within tracked contexts: a blob can only have one tracked attachment
#
# With per-attachment reconcile, blob reuse inside an account wouldn't break correctness -
# ledger would still count each attach, and reconcile would agree. However, we intentionally
# forbid reuse (except templates) as a product/control decision:
# - Simpler mental model (one blob = one attachment)
# - Prevents accidental quota manipulation via direct blob_id reuse
# - Cleaner audit trail in ledger entries
#
# Scope note: The no-reuse validation only blocks reuse when the *new* attachment is tracked
# AND only checks *existing* attachments in Storage::TRACKED_RECORD_TYPES. A blob first
# attached to an untracked type (avatar/export) could theoretically be reused in a tracked
# context. This is acceptable - user-accessible blob IDs from untracked contexts are
# basically nonexistent in practice.
#
# Exception: ActionText embeds are allowed to reuse blobs to support copy/paste.
ActiveSupport.on_load(:active_storage_attachment) do
validate :blob_account_matches_record, on: :create
validate :no_tracked_blob_reuse, on: :create
private
# Multi-tenant safety: blob must belong to same account as record
# NOTE: Skips validation if record.account is nil. This is a theoretical bypass
# if someone attaches before account assignment, but our flows assign account
# before attachment. Global/unaccounted attachments (Identity/User avatars, exports)
# bypass tenancy checks via try(:account) returning nil - this is intentional as
# these classes don't participate in storage tracking.
def blob_account_matches_record
if record&.try(:account).present? && !whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
end
end
end
# Ledger integrity: blob can only have one tracked attachment
def no_tracked_blob_reuse
tracked_record = record&.try(:storage_tracked_record)
if tracked_record.present? &&
!whitelisted_for_cross_account? &&
!(record_type == "ActionText::RichText" && name == "embeds")
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
end
end
end
def whitelisted_for_cross_account?
# Only template account blobs can be reused cross-tenant.
# When TEMPLATE_ACCOUNT_ID is nil, no exemptions are granted.
Storage::TEMPLATE_ACCOUNT_ID.present? && blob&.account_id == Storage::TEMPLATE_ACCOUNT_ID
end
end