68 lines
3.0 KiB
Ruby
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
|