How to Secure and Audit a Rails Console

Comply with relevant laws, policies, and local regulations while also maintaining your peace of mind.

Original photo by Markus Spiske from Pexels

Every rails developer knows the power of the rails console. Many bugs we encounter in production can be fixed in less than a minute by running scripts directly into the console. It’s not an ideal workflow, but it can put out a lot of fires in production while keeping our clients happy.

The only downside of using the console in production is that there is no way to audit what changes were made, and by whom. As the company and its dev team grow, this becomes more of a problem with each passing day.

This tutorial will teach you how to secure and audit a rails console. That way, you can comply with relevant laws, policies, and local regulations while also maintaining your peace of mind.

Setup


Before we begin, let's set up a simple rails app so you can code along before trying it out in a real project. Following the rails tradition, we are going to create a blog. Feel free to copy and paste the next block of code to create one as well.

rails new blog --database=postgresql --skip-spring &&
cd blog &&
rails g model Post title:string body:text &&
bundle add devise &&
rails g devise:install &&
rails g devise Admin &&
rails db:create &&
rails db:migrate

The code block above is pretty straightforward so I won’t explain it in detail, but we now have the simplest app needed for this article.

Before we continue, make sure you go into the console of our newest app to create an Admin and a Post record with the code below. We will use these records for the rest of this article.

Admin.create(email: 'admin‎@example.com', password: 'password')
Post.create(title: 'My Title', body: 'Lorem Ipsum')

Secure


Let's start by securing our rails console.

In our blog app, Admin is a table that holds the records of every developer in our company with production access.

Every Admin has an email and password, which they will use to log into our console. To create this logic, we are going to monkey patch our console initialization method. Create a file named console_monkey_patch.rb in config/initializers with the following code:

module Rails
   module ConsoleMethods
     def self.included(_base)
       puts 'Hello, dev! ヾ(^-^)ノ'
       puts "Welcome to the #{Rails.env} environment!"
       print 'Enter your Admin email: '
 

       email = gets
       user = Admin.find_by(email: email.strip)
 

       unless user
         puts 'Admin not found! Exiting...'
         exit
       end
 

       print 'Enter your password: '
       pass = IO::console.getpass
       if user.valid_password?(pass.strip)
         puts "\nWelcome, #{user.email}!"
       else
         puts 'Provided password is incorrect! Exiting...'
         exit
       end
     end
   end
 end

Now let's take a closer look at what this code is doing.

We are first going to ask the user to type their email. If the email doesn't exist as a record of our Admin table, we are already going to exit the console. If the email does exist, we are going to ask for the user's password. Finally, if the password is correct for that email, the user will be able to enter the console.

Note: You can add return unless Rails.env.production? right at the beginning of the method if you want this logic to run in production only.

Beware that the method IO::console.getpass is part of the standard library since ruby 2.3. If you are using an old ruby version you have to change line 19 with the following code:

pass = $stdin.noecho(&:gets)
pass.strip!

Also, the method valid_password? is part of the devise gem, so if you're not using it you have to add your own password verification method in this line.

Audit


Now for the audit part.

With our console secure, we now want to track down what our Admins are doing after they enter the console. There are many ways you can do this and in this tutorial, we will do it using the audited gem.

Install the gem with the following command:

bundle add audited

Then we have to run its installer and migrate a new table into our database.

rails g audited:install
rails db:migrate

Now we have to go through all the models of our application and chose the ones we are going to add Audited to. This is important because you are going to generate a lot of log records in your database, so make sure you add it to your most important models only.

In our blog application, our most important table is Post, and this is all the configuration we need to start tracking it:

# app/models/post.rb
class Post < ApplicationRecord
  audited
end

With that setting, we are now logging everything that happens with our Post table. Every creation, update, and deletion in every field will be recorded.

I recommend limiting your logs to a few fields and actions only. As an example, we could tell audited to only log updates made to the title and body of our Post table, like so:

# app/models/post.rb
class Post < ApplicationRecord
  audited only: [:title, :body], on: [:update]
end

Audited has very good documentation so I highly recommend you read it to find out all that you can do with it.

Auditing with Audited

Let’s play around with Audited before we continue. Login into our console with the Admin credentials we created earlier and update the title of our Post record.

Post.first.update(title: "Another title")

Then, check the log it generated with the following command:

Post.first.audits.last

If you are using awesome print, your log will probably look like this:


As you can see, Audited just logged an update to the title of our Post. Perfect!

But can you spot what's missing here? Notice that user_id and user_type are currently nil. That means we did not associate our logged Admin with the change we just made.

To fix that, we need to edit our console_monkey_patch.rb file one more time, adding the line Audited.store[:audited_user] = user right after the password validation step.

# app/config/initializers/console_monkey_patch.rb
...
if user.valid_password?(pass.strip)
  Audited.store[:audited_user] = user # <- NEW LINE  
  puts "Welcome, #{user.name}!"      
else        
  puts 'Provided password is incorrect! Exiting...'        
  exit      
end
...

This line will tell Audited to connect our logged Admin with all the actions they execute during that console section.

To test it out, log in again and update the same Post with a new title.

Post.first.update(title: "Yet another title")

Now check the last log again.

Post.first.audits.last

This is the result you should be looking at:


Notice that we now have user_id and user_type in our log object, so we know who did the change.

If you want, you can even get the user object straight from the audit log with the following command.

Post.first.audits.last.user
=> #<Admin id: 1, email: "admin‎@example.com", created_at: "2020-11-03 03:07:30", updated_at: "2020-11-03 03:07:30">

And if you want to search for all the changes made by that specific user, you can easily do that with the following command:

user = Admin.find_by(email: "admin‎@example.com")
Audited::Audit.where(user: user)

Caveats


1. Too many logs

The Audited configuration we added to our model not only affects the changes made in the rails console, but all the changes happening in our application. That means that if you have a large database with lots of updates happening all the time, you should probably think twice before adding Audited to all fields and actions in all of your tables.

To help reduce the number of logs generated throughout your entire application, you can add a configuration to limit the number of logs generated per table record:

# config/initializers/audited.rb
Audited.max_audits = 5 # keep only latest audits

The code above tells Audited to store only 5 logs per table record. If we generate a sixth log, the oldest one will be deleted to accommodate the new one. Note that you can tweak that number to suit your needs.

Again, I recommend that you read Audited's documentation to learn more about how Audited can help you reduce the number of logs generated.

2. Users can impersonate other users

Maybe you already guessed it, but this setting is not fail-proof. What happens if a developer runs the following code in the console?

user = Admin.find_by(email: 'another_admin‎@example.com')
Audited.store[:audited_user] = user

That's right. After those lines, an Admin is free to do whatever he or she wants in the console while pretending to be someone else.

Depending on your setup, one workaround you can do is to search for "console" in your application logs in the event of an incident.  Every time someone opens up a console, you should see something like this in your logs:

Nov 03 19:17:34 Starting process with command `bin/rails console` by user admin‎@example.com

In the case of an incident, you can cross-check your database timestamps to find out if the Audited user matches the user that opened up the console before that moment.

I know this is still not perfect – and obviously time-consuming, so if you have any other idea on how to deal with this problem I would love to hear it!

Conclusion


This concludes our article about securing and auditing a rails application in production. Let me know your thoughts about this technique in the comments and I'll see you in the next article!