we-promise/sure · error · Category::Merger::UnauthorizedCategoryError

#{label} does not belong to this family

Error message

#{label} does not belong to this family

What it means

Category::Merger#validate_category_belongs_to_family! raises UnauthorizedCategoryError when a category passed as the merge target or as one of the sources belongs to a different family than the merger's family. It is a multi-tenant ownership guard: category.family_id must equal family.id for every category handed to the merge.

Source

Thrown at app/models/category/merger.rb:42

    Category.transaction { merge_sources! }
    true
  end

  private
    def merge_sources!
      source_categories.each do |source|
        family.transactions.where(category_id: source.id).update_all(category_id: target_category.id)
        merge_budget_categories(source)
        family.categories.where(parent_id: source.id).where.not(id: target_category.id).update_all(parent_id: target_category.id)
        family.categories.find(source.id).destroy!
        @merged_count += 1
      end
    end

    def validate_category_belongs_to_family!(category, label)
      return if category&.family_id == family.id

      raise UnauthorizedCategoryError, "#{label} does not belong to this family"
    end

    def validate_hierarchy!
      target_ancestor_ids = ancestor_ids_for(target_category)
      return unless source_categories.any? { |source| target_ancestor_ids.include?(source.id) }

      raise UnauthorizedCategoryError, "A parent category cannot be merged into its own subcategory"
    end

    def validate_reparenting!
      return if target_category.parent_id.blank?
      return unless source_categories.any? { |source| family.categories.exists?(parent_id: source.id) }

      raise UnauthorizedCategoryError, "Cannot merge a category with subcategories into a subcategory"
    end

    def ancestor_ids_for(category)
      ids = []

View on GitHub (pinned to e69894adb9)

Solutions

  1. Resolve every category through the family scope: family.categories.find(id) instead of Category.find(id)
  2. Return 404/422 from the endpoint when an ID does not exist within the current family instead of letting the merger raise
  3. If occurrences are real traffic, audit the client for cross-family ID leakage

Example fix

// before
target = Category.find(params[:target_id])
merger = Category::Merger.new(family: family, target_category: target, source_categories: sources)

// after
target = family.categories.find(params[:target_id]) # RecordNotFound stays scoped to the family
merger = Category::Merger.new(family: family, target_category: target, source_categories: sources)
Defensive patterns

Strategy: validation

Validate before calling

family.categories.where(id: target_category_ids + source_category_ids).count == ids.uniq.length # all IDs resolvable within the family

Try / catch

rescue Category::Merger::UnauthorizedCategoryError (or the merger's error class) in the controller and return 422 without leaking that the IDs exist in another family

Prevention

When it happens

Trigger: Building Category::Merger.new(family:, target_category:, source_categories:) with category objects fetched outside the family scope — params-supplied IDs resolved with Category.find, IDs belonging to another family, or stale client IDs after a family switch.

Common situations: Controller/API endpoints that trust client-provided category IDs without scoping to current_family; scripts or imports referencing category IDs across families; tests using fixtures from a different family.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/7663358d8cfc0424. Report an issue: GitHub.