ruby-on-railsrubydevisenested-attributesstrong-parameters

Rails Devise Strong Parameters not building Nested Association


I did generated devise controllers and views and defined both User and Account models like the following:

User

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :timeoutable, :trackable,
         :omniauthable, omniauth_providers: [ :google_oauth2 ]

  has_one :account, autosave: true, dependent: :destroy, inverse_of: :user

  before_validation :set_account
  enum :role, { basic: 0, admin: 1, courier: 2, client: 3 }

  def set_account
    self.build_account
  end
  accepts_nested_attributes_for :account
end

Account

class Account < ApplicationRecord
  belongs_to :user, autosave: true
end

Strong parameter config

class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_signup_parameters, only: :create
.
.
.
 protected

  # If you have extra params to permit, append them to the sanitizer.
  def configure_signup_parameters
    added_attrs = [ :first_name,
                    :last_name,
                    :name,
                    :email,
                    :password,
                    :password_confirmation,
                    :card_name,
                    :card_number,
                    :card_cvv,
                    :card_expiration_month,
                    :card_expiration_year,
                    account_attributes: [
                      :name,
                      :plan,
                      :plan_discount_pct,
                      :plan_period,
                      :plan_price,
                      :tier
                    ]
    ]
    devise_parameter_sanitizer.permit(:sign_up, keys: added_attrs)
  end
end

In the logs you can see that the data for the nested attributes are there but following that you see rails creating the Account with all empty values:

Processing by Users::RegistrationsController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>**{"account_attributes"=>{"tier"=>"corporate", "plan"=>"SMB", "name"=>"Batman", "plan_period"=>"GoldAnnual"}**, "first_name"=>"Bruce", "last_name"=>"Wayne", "name"=>"Batman", "email"=>"[FILTERED]", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "card_name"=>"Bruce Wayne", "card_number"=>"[FILTERED]", "card_expiration_month"=>"[FILTERED]", "card_expiration_year"=>"[FILTERED]", "card_cvv"=>"[FILTERED]", "save"=>"1", "commit"=>"Submit"}
  TRANSACTION (0.2ms)  BEGIN /*action='create',application='Freighteen',controller='registrations'*/
  User Exists? (6.5ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = 'batman@dc.com' LIMIT 1 /*action='create',application='Freighteen',controller='registrations'*/
  CACHE User Exists? (0.0ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = 'batman@dc.com' LIMIT 1
  User Create (0.5ms)  INSERT INTO "users" ("email", "encrypted_password", "reset_password_token", "reset_password_sent_at", "remember_created_at", "sign_in_count", "current_sign_in_at", "last_sign_in_at", "current_sign_in_ip", "last_sign_in_ip", "confirmation_token", "confirmed_at", "confirmation_sent_at", "unconfirmed_email", "failed_attempts", "unlock_token", "locked_at", "created_at", "updated_at", "name", "first_name", "last_name", "role") 

VALUES ('batman@dc.com', '$2a$12$MoG2NgRuikEE9yEMguK4iOybH7MN3BmDRS6sf78SUNJpK.rx9z3Cu', NULL, NULL, NULL, 0, NULL, NULL, NULL, NULL, 'sGBMeBMesDm-2h3j9Jcj', NULL, '2025-01-05 04:41:22.332196', NULL, 0, NULL, NULL, '2025-01-05 04:41:22.331966', '2025-01-05 04:41:22.331966', 'Batman', 'Bruce', 'Wayne', 0) RETURNING "id" /*action='create',application='Freighteen',controller='registrations'*/
  **Account Create (0.6ms)  INSERT INTO "accounts" ("user_id", "created_at", "updated_at", "tier", "plan", "plan_period", "plan_discount_pct", "plan_price", "name") VALUES (11, '2025-01-05 04:41:22.341682', '2025-01-05 04:41:22.341682', NULL, NULL, NULL, NULL, 0.0, NULL) RETURNING "id"**

Note that account attributes are present but don't get created.

What am I missing? I didn't think I have to build manually given the configuration.


Solution

  • The first issue here is that your set_account callback is replacing the user input with a new instance of Account. Do not use model callbacks to "seed" associations for a form.

    class User < ApplicationRecord
      # Include default devise modules. Others available are:
      # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
      devise :database_authenticatable, :registerable,
             :recoverable, :rememberable, :validatable,
             :confirmable, :lockable, :timeoutable, :trackable,
             :omniauthable, omniauth_providers: [ :google_oauth2 ]
    
      has_one :account, autosave: true, dependent: :destroy, inverse_of: :user
      enum :role, { basic: 0, admin: 1, courier: 2, client: 3 }
      accepts_nested_attributes_for :account
    end
    

    Instead do it in the controller so that it only actually happens when you need it. All the Devise controllers have cleverly placed yields to make it really easy to tap into the flow.

    class Users::RegistrationsController < Devise::RegistrationsController
      before_action :configure_signup_parameters, only: :create
      # ...
    
      def new
        super do |user|
          user.build_account
        end
      end
    end
    

    There are also some rather egregious code smells to what input you're accepting from the user and how you're modeling your data.

    Surely the user should not be able to choose whatever price and rebates they want? What happens when I send plan_discount_pct=100 with cURL? Guess it's free then? Thanks.

    Create a Plan model and have the user select the ID of a plan instead and you'll also avoid denormalizing your data.

    You really should not be storing credit card details, you're a single SQL injection attack away from some serious litagation. Let whatever payment vendor you're using take care of it instead. If you really really wanted to this use a separate table with application level encryption and make the association one to many so that you can actually track what card was used for what payment.