The Support Tool That Edits Five Tables Atomically: Building a Reparent-User Workflow With a Dry-Run Diff

Every B2B SaaS support team has a request type they secretly hate. For one client, the request was: “this user signed up under the wrong company. Please move their account.”

The reason support hated it was that the answer was always “we’ll need to escalate to engineering.” The reason engineering hated it was that the actual operation was not one UPDATE statement, it was a transaction across five tables with several denormalized counter caches that needed to be carefully maintained on both sides of the move. The engineer who got escalated had to context-switch out of whatever they were doing, open a Rails console, and run a sequence of commands that had been refined over years but lived nowhere except in a senior engineer’s muscle memory.

This is the story of building a self-service version of that operation: a Rails Admin action, scoped to the Super Admin internal role, that runs inside a single database transaction, produces a structured diff preview before committing, and is safe enough that a support engineer can use it without an engineering escalation.

The pattern (dry-run preview + atomic transaction + role-scoped exposure) generalizes to roughly every “risky internal operation” in a SaaS product. Once we built one, we built six more in the following year. This article is about the first one.

The shape of the operation

Moving a user from company A to company B is not one write. It is at minimum five:

  1. users.company_id changes from A to B. The visible part.
  2. user_group_memberships for the moved user, scoped to company A, are removed (the user is no longer in any of company A’s user groups). New default memberships are created in company B.
  3. transactions authored by this user keep their user_id reference. We do not re-parent the transactions; they belong to the original company that owned them. This means the user’s transaction history visible to them now includes records that company B’s admins cannot see — which is intentional and worth surfacing in the diff preview.
  4. inspection_stats is a denormalized aggregate. Moving the user does not change historical inspection counts (those stay with company A’s records), but the user’s current-period contributions to inspection_stats need to be reconsidered. In practice we recompute the affected day-buckets on both sides after the move.
  5. audit_log gets a structured entry: actor, source company, target company, reason, user identifier. This is non-optional.

Plus the counter cache update: companies.user_count decrements on the source side and increments on the target side. (We use Rails counter_cache for this, which works as long as the operation respects ActiveRecord callbacks — i.e., does not use update_columns.)

Five tables, one counter cache, one denormalized aggregate. All of it has to land or none of it does. This is the most basic transaction guarantee a SaaS engineering team owes itself.

The naive version

The legacy version of this operation was a rake task or a Rails console snippet, roughly:

# DO NOT DO IT THIS WAY
user = User.find(user_id)
user.update!(company_id: target_company.id)
user.user_group_memberships.destroy_all
target_company.default_user_groups.each do |group|
  group.user_group_memberships.create!(user: user)
end

This was wrong in three ways:

  1. No transaction. A failure halfway through left the user in a corrupt state — moved on users, but still a member of the source company’s groups.
  2. No dry-run. The operator had to either run it and hope, or sketch the diff in their head before committing. The latter took ten minutes per case and was error-prone.
  3. No audit log. When a customer later asked “why is this user in our company,” the answer was “I’m not sure, let me ask around.”

The right shape is a transactional, dry-runnable, audit-logged action exposed only to roles that should have it. That is what we built.

The architecture

A service object holds the operation. The Rails Admin action thin-wraps it.

class MoveUserToCompany
  Result = Struct.new(:committed, :diff, :error, keyword_init: true)

  def initialize(user:, target_company:, actor:, reason:, dry_run: true)
    @user = user
    @source_company = user.company
    @target_company = target_company
    @actor = actor
    @reason = reason
    @dry_run = dry_run
  end

  def call
    raise CrossTenantBlocked unless authorized?
    raise SameCompanyMove if @source_company == @target_company

    diff = compute_diff

    if @dry_run
      return Result.new(committed: false, diff: diff)
    end

    ActiveRecord::Base.transaction do
      perform_user_move
      replace_user_group_memberships
      enqueue_inspection_stats_recompute
      record_audit_log
    end

    Result.new(committed: true, diff: diff)
  rescue ActiveRecord::RecordInvalid => e
    Result.new(committed: false, diff: nil, error: e.message)
  end

  private

  def authorized?
    @actor.role == "super_admin"
  end

  def compute_diff
    {
      user: { id: @user.id, email: @user.email },
      source_company: { id: @source_company.id, name: @source_company.name },
      target_company: { id: @target_company.id, name: @target_company.name },
      changes: {
        user_company_id: [@source_company.id, @target_company.id],
        groups_removed: removed_groups.map { |g| { id: g.id, name: g.name } },
        groups_added: added_groups.map { |g| { id: g.id, name: g.name } },
        transactions_remaining_in_source: source_transactions_count,
        counter_cache_deltas: {
          "#{@source_company.id}.user_count" => -1,
          "#{@target_company.id}.user_count" => +1
        }
      },
      warnings: warnings_for_move
    }
  end

  def perform_user_move
    @user.update!(company_id: @target_company.id)
  end

  def replace_user_group_memberships
    @user.user_group_memberships.where(company_id: @source_company.id).destroy_all
    @target_company.default_user_groups.each do |group|
      group.user_group_memberships.create!(user: @user)
    end
  end

  def enqueue_inspection_stats_recompute
    InspectionStatRecomputeJob.perform_later(
      company_ids: [@source_company.id, @target_company.id],
      day_range: 30.days.ago..Date.tomorrow
    )
  end

  def record_audit_log
    AuditLog.create!(
      actor_id: @actor.id,
      action: "move_user_to_company",
      payload: {
        user_id: @user.id,
        source_company_id: @source_company.id,
        target_company_id: @target_company.id,
        reason: @reason,
        diff: compute_diff
      }
    )
  end

  def warnings_for_move
    warnings = []
    if source_transactions_count > 0
      warnings << {
        kind: "transactions_remaining_in_source",
        count: source_transactions_count,
        message: "This user has #{source_transactions_count} transactions that will REMAIN owned by #{@source_company.name}. They will be visible to the user but not to #{@target_company.name} admins."
      }
    end
    warnings
  end

  # ... helper methods elided for length ...
end

The whole operation is a single transaction. The diff is computed before the transaction opens, displayed to the operator, and re-computed inside the transaction for the audit log so the recorded diff matches the actual writes. The warning generator surfaces the non-obvious consequences — like the fact that the user’s prior transactions stay with the source company — which the operator needs to understand before they confirm.

The dry-run preview UI

The Rails Admin action takes a dry_run parameter that defaults to true. The first submission shows the operator a structured preview:

You are about to move USER <jane.doe@example.com> (id 1817)
  FROM company "Acme Rentals" (id 91)
  TO company "Acme Holdings" (id 247)

Changes that will be applied:
  - users.company_id: 91 → 247
  - Remove 4 user group memberships in Acme Rentals
    - "Field Operations" (id 412)
    - "Reporting Read-Only" (id 419)
    - "Default" (id 401)
    - "Equipment Inspector" (id 433)
  - Add 2 user group memberships in Acme Holdings
    - "Default" (id 815)
    - "Equipment Inspector" (id 819)
  - Counter cache deltas:
    - companies(91).user_count: -1
    - companies(247).user_count: +1
  - Enqueue inspection_stats recompute for companies (91, 247) over last 30 days

WARNINGS:
  - This user has 38 transactions that will REMAIN owned by Acme Rentals.
    They will be visible to the user but not to Acme Holdings admins.

[ Confirm and apply ]   [ Cancel ]

The Confirm button re-submits with dry_run=false. The actual write only happens then.

The diff is generated by the same code that performs the write, walking the same association chains. There is no separate “preview code path” that can drift from the real one. This is critical. The two most-common ways a dry-run-then-commit pattern fails are: (a) the preview is wrong because it was hand-written and not re-derived, or (b) the commit happens with different parameters than the preview was computed against. Both are avoided by computing the diff inside the service object, holding it as data, and writing the same data structure into the audit log when the commit lands.

Role scoping

The action is exposed only to the Super Admin internal role. The new permissions matrix this client introduced distinguished six internal roles, and “Moving User Accounts into Customer Companies” was a row in that matrix with exactly two TRUE values: Super Admin, and Engineering. Customer Support cannot do this without escalating. Sales, Marketing, and Customer Success cannot do it at all.

# Rails Admin action registration, conceptually
class MoveUserAction < RailsAdmin::Config::Actions::Base
  RailsAdmin::Config::Actions.register(self)

  register_instance_option :only do
    [User]
  end

  register_instance_option :authorization_key do
    :move_user_to_company
  end

  register_instance_option :controller do
    proc do
      target_company = Company.find(params[:target_company_id])
      result = MoveUserToCompany.new(
        user: @object,
        target_company: target_company,
        actor: current_user,
        reason: params[:reason],
        dry_run: params[:confirm] != "true"
      ).call

      if result.committed
        flash[:success] = "User moved to #{target_company.name}"
        redirect_to back_or_index
      elsif result.diff
        render "rails_admin/main/move_user_dry_run", locals: { result: result }
      else
        flash[:error] = result.error
        redirect_to back_or_index
      end
    end
  end
end

The authorization check is enforced at the ability layer:

class Ability
  def initialize(user)
    case user.role
    when "super_admin"
      can :move_user_to_company, User
    when "engineering"
      can :move_user_to_company, User
    else
      cannot :move_user_to_company, User
    end
  end
end

The role check duplicates the one inside the service object on purpose. The service object’s check is the authoritative one. The Rails Admin check is for UI hygiene — it hides the button from users who cannot use it.

What it replaced

Before this action existed, the operation took:

After:

The math is roughly two engineer-days a month back in the engineering team’s calendar, plus the more important qualitative win: the operation is now traceable. Every move has a recorded actor, reason, and diff. When a customer later asks “who moved this user and why,” the answer is a SQL query against audit_log.

The pattern generalizes

After this one shipped, the team built six more “risky internal operations” with the same shape:

Each of them has the same skeleton: a service object that holds the operation, a dry_run parameter defaulting to true, a structured diff computed by the same code that performs the write, a single ActiveRecord transaction, an audit log entry, and a role-scoped Rails Admin action.

The skeleton is worth more than the individual operations. Once you have it, adding a new risky operation is half a day, not a week. The team eventually had an internal name for the pattern (“RA-action”), a generator that produced the skeleton, and a code review checklist that every RA-action had to pass before it was merged.

The takeaway

Risky internal operations should not live in a senior engineer’s head. They should be code, with a dry-run preview, an atomic transaction, an audit log entry, and a role-scoped UI. The operation that took fifteen minutes of senior engineer time per case now takes three minutes of support time, runs in a transaction, and is fully traceable.

If your team is escalating customer support requests to engineering because the underlying operation involves more than one table, this is the pattern that gets you out. Build the skeleton once. Apply it everywhere the support team currently waits on engineering.

This was one of a body of similar internal-tooling engagements. Happy to talk about yours.