Rails 7.1.x Secured Namespaced Controllers

Organizing and Securing Controllers by Usage

I have found in large code bases Modules and namespaces are critical.

Here is a simple way to build a secured admin panel using namespaces.

This code can be found at: https://github.com/btihen-dev/rails_secured_namespace

Getting Started

I will create a basic application using:

rails new secured_namespace -d=postgresql -T
cd secured_namespace

bin/rails g scaffold Contact email first_name last_name message:text

bin/rails db:create
bin/rails db:migrate

for simplicity update the root route with root "contacts#new" contacts as your landing page i.e.:

# config/routes.rb
Rails.application.routes.draw do
  resources :contacts

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Defines the root path route ("/")
  root "contacts#new"
end

be sure this all works and commit:

git add .
git commit -m "add new contact to home page"

In fact, we only only want users to submit a contact request and noting else (via HTML) - so lets simplify the controller and views to:

# app/controllers/contacts_controller.rb
class ContactsController < ApplicationController
  def new
    @contact = Contact.new
  end

  def create
    @contact = Contact.new(contact_params)

    if @contact.save
      redirect_to root_path, notice: "Contact was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  # Only allow a list of trusted parameters through.
  def contact_params
    params.require(:contact).permit(:email, :first_name, :last_name, :message)
  end
end

now we can also delete all the unreferenced views

├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── app
│   ├── controllers
│   │   ├── application_controller.rb
│   │   ├── concerns
│   │   └── contacts_controller.rb
│   ...
│   └── views
│       ├── contacts
│       │   ├── _form.html.erb
│       │   └── new.html.erb
│       └── layouts
│           ├── application.html.erb
│           ├── mailer.html.erb
│           └── mailer.text.erb

test again and be sure all still works and commit:

git add .
git commit -m "restrict user to submitting a contact request"

Admin Panel

Now of course we need to access these contacts - and we will restrict this to logged in admins. Let’s build a namespaced controller,

rails g scaffold_controller admin/contact

Now we need to make some adjustments, let’s start with thee controller. we need to replace: Admin::Contacts. with Contact. and

# app/controllers/admin/contacts_controller.rb
...

  # Only allow a list of trusted parameters through.
  def admin_contact_params
    params.fetch(:admin_contact, {})
  end
end

with:

# app/controllers/admin/contacts_controller.rb
...

  # Only allow a list of trusted parameters through.
  def admin_contact_params
    params.require(:contact).permit(:email, :first_name, :last_name, :message)
  end
end

so the controller now looks like:

# app/controllers/admin/contacts_controller.rb
class Admin::ContactsController < ApplicationController
  before_action :set_admin_contact, only: %i[ show edit update destroy ]

  # GET /admin/contacts or /admin/contacts.json
  def index
    @admin_contacts = Contact.all
  end

  # GET /admin/contacts/1 or /admin/contacts/1.json
  def show
  end

  # GET /admin/contacts/new
  def new
    @admin_contact = Contact.new
  end

  # GET /admin/contacts/1/edit
  def edit
  end

  # POST /admin/contacts or /admin/contacts.json
  def create
    @admin_contact = Contact.new(admin_contact_params)

    respond_to do |format|
      if @admin_contact.save
        format.html { redirect_to admin_contact_url(@admin_contact), notice: "Contact was successfully created." }
        format.json { render :show, status: :created, location: @admin_contact }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @admin_contact.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /admin/contacts/1 or /admin/contacts/1.json
  def update
    respond_to do |format|
      if @admin_contact.update(admin_contact_params)
        format.html { redirect_to admin_contact_url(@admin_contact), notice: "Contact was successfully updated." }
        format.json { render :show, status: :ok, location: @admin_contact }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @admin_contact.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /admin/contacts/1 or /admin/contacts/1.json
  def destroy
    @admin_contact.destroy!

    respond_to do |format|
      format.html { redirect_to admin_contacts_url, notice: "Contact was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_admin_contact
    @admin_contact = Contact.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def admin_contact_params
    params.require(:contact).permit(:email, :first_name, :last_name, :message)
  end
end

Now in the views we need to rename: app/views/admin/contacts/_contact.html.erb to app/views/admin/contacts/_admin_contact.html.erb and fill it with:

# app/views/admin/contacts/_admin_contact.html.erb
<div id="<%= dom_id admin_contact %>">
  <p>
    <strong>Email:</strong>
    <%= admin_contact.email %>
  </p>

  <p>
    <strong>First name:</strong>
    <%= admin_contact.first_name %>
  </p>

  <p>
    <strong>Last name:</strong>
    <%= admin_contact.last_name %>
  </p>

  <p>
    <strong>Message:</strong>
    <%= admin_contact.message %>
  </p>
</div>

now we can adjust index to use this partial - we need to change: <%= render admin_contact %> to <%= render 'admin_contact', admin_contact: admin_contact %> and <%= link_to "Show", admin_contact %> to <%= link_to "Show", admin_contact_path(admin_contact) %> I also like to use edit instead of show - so now the index page looks like:

# app/views/admin/contacts/index.html.erb
<p style="color: green"><%= notice %></p>

<h1>Contacts</h1>

<div id="admin_contacts">
  <% @admin_contacts.each  do |admin_contact| %>
    <%= render 'admin_contact', admin_contact: admin_contact %>
    <p>
    <%= link_to "Show", admin_contact_path(admin_contact) %>
    <%= link_to "Edit", edit_admin_contact_path(admin_contact) %>
    </p>
  <% end  %>
</div>

<%= link_to "New contact", new_admin_contact_path %>

let’s check show too - again we need to change <%= render admin_contact %> to <%= render 'admin_contact', admin_contact: admin_contact %>

now we need to update the admin forms - since rails assumes our variable will also be scoped (even though its not) so we pass the form the action’s URL so we changeL <%= form_with(model: admin_contact) do |form| %> to <%= form_with(model: admin_contact, url: form_url) do |form| %>

thus it now looks like:

# app/views/admin/contacts/_form.html.erb
<%= form_with(model: admin_contact, url: form_url) do |form| %>
  <% if admin_contact.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(admin_contact.errors.count, "error") %> prohibited this admin_contact from being saved:</h2>

      <ul>
        <% admin_contact.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end  %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.submit %>
  </div>
<% end  %>

now we can pass the url to the form - in edit and new so the now call to the for would look like - to figure out the route path type: bin.rails routes and math the action to the form.

<%= render(
      "form",
      admin_contact: @admin_contact,
      form_url: admin_contact_path(@admin_contact) # PATCH /admin_contact => admin_contacts#update
    ) %>

now all-to gether im the form:

# app/views/admin/contacts/_form.html.erb

<%= form_with(model: admin_contact, url: form_url) do |form| %>

<% if admin_contact.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(admin_contact.errors.count, "error") %> prohibited this contact from being saved:</h2>

      <ul>
        <% admin_contact.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end  %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :email, style: "display: block" %>
    <%= form.text_field :email %>
  </div>

  <div>
    <%= form.label :first_name, style: "display: block" %>
    <%= form.text_field :first_name %>
  </div>

  <div>
    <%= form.label :last_name, style: "display: block" %>
    <%= form.text_field :last_name %>
  </div>

  <div>
    <%= form.label :message, style: "display: block" %>
    <%= form.text_area :message %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end  %>

Now the edit view:

# app/views/admin/contacts/edit.html.erb
<h1>Editing contact</h1>

<%= render(
      "form",
      admin_contact: @admin_contact,
      # PATCH /admin_contact => admin_contacts#update
      form_url: admin_contact_path(@admin_contact)
    ) %>

<br>

<div>
  <%= link_to "Show this contact", admin_contacts_path(@admin_contact) %> |
  <%= link_to "Back to contacts", admin_contacts_path %>
</div>

and now in new

# app/views/admin/contacts/new.html.erb
<h1>New contact</h1>

<%= render(
            "form",
            admin_contact: @admin_contact,
            # POST /admin_contacts => admin_contacts#create
            form_url: admin_contacts_path(@admin_contact)
          )%>

<br>

<div>
  <%= link_to "Back to contacts", admin_contacts_path %>
</div>

and show also needs updating to all allow deletions:

#
<p style="color: green"><%= notice %></p>

<%= render 'admin_contact', admin_contact: @admin_contact %>

<div>
  <%= link_to "Edit", edit_admin_contact_path(@admin_contact) %> |
  <%= link_to "List contacts", admin_contacts_path %>

  <%= button_to "Destroy", admin_contacts_path(@admin_contact), method: :delete %>
</div>

Now if we go to: localhost:3000/admin/contacts we should be able to see and edit out contacts

let’s commit:

git add .
git commit -m "admin section with full management

Simple Authentication & Authorization

Let’s restrict the admin page to logged in users.

We can easily do this with Devise: https://github.com/heartcombo/devise which has great instructions which I will summarize here.

bundle add devise
rails generate devise:install

# in config/environments/development.rb:
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

rails generate devise User
bin/rails db:migrate

# in app/views/layouts/application.html.erb.
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
# to this area:
<body>
  <p class="notice"><%= notice %></p>
  <p class="alert"><%= alert %></p>
  <%= yield %>
</body>

# we need to generate the views to remove the signup links
rails g devise:views

Now you can adjust the how devise works with the user in the User model in this case we do NOT want people to register on their own so we will remove registerable in:

# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, # :registerable,
         :recoverable, :rememberable, :validatable

  normalizes :email, with: -> email { email.downcase.strip }
end

Devise without sign-up article: https://medium.com/@tdaniel/removing-user-registration-from-devise-3baa8d1738be

Now we want to remove the registerable links and forms so we change the line: devise_for :users to devise_for :users, :skip => [:registrations]

now routes should look like

# config/routes.rb
Rails.application.routes.draw do
  devise_for :users, :skip => [:registrations]

  namespace :admin do
    resources :contacts
  end
  resources :contacts

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Defines the root path route ("/")
  root "contacts#new"
end

Now we need to remove the sign-up / new_registration link in app/views/devise/shared/_links.html.erb as the following code will now error:

<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
  <%= link_to "Sign up", new_registration_path(resource_name) %><br />
<% end %>

so now this file should look like:

# app/views/devise/shared/_links.html.erb
<%- if controller_name != 'sessions' %>
  <%= link_to "Log in", new_session_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
  <%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
  <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
<% end  %>

<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
  <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.omniauthable? %>
  <%- resource_class.omniauth_providers.each do |provider| %>
    <%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br />
  <% end  %>
<% end %>

let’s check that there is no sign-up route with:

bin/rails routes

Prefix               Verb   URI Pattern                    Controller#Action
-------------------------------------------------------------------
new_user_session     GET    /users/sign_in(.:format)       devise/sessions#new
user_session         POST   /users/sign_in(.:format)       devise/sessions#create
destroy_user_session DELETE /users/sign_out(.:format)      devise/sessions#destroy
new_user_password    GET    /users/password/new(.:format)  devise/passwords#new
edit_user_password   GET    /users/password/edit(.:format) devise/passwords#edit
user_password        PATCH  /users/password(.:format)      devise/passwords#update
                     PUT    /users/password(.:format)      devise/passwords#update
                     POST   /users/password(.:format)      devise/passwords#create

now it is important to restart rails if already running!

Let’s create a new user from the Command line (since we can no longer register via a web interface):

User.create!({:email => "test@example.com", :password => "1123456789-asdfg", :password_confirmation => "1123456789-asdfg" })

If you have confirmable module enabled for devise, make sure you are setting the confirmed_at value to something like Time.now while creating. In our case this should not be the case.

The easiest way to secure the entire application is to now add: before_action :authenticate_user! to our application_controller.

# app/controllers/application_controller.rb
class ApplicationController < ApplicationController
  before_action :authenticate_user!
end

And the add skip_before_action :authenticate_user! to our root page so that it can be accessed without a login, so it now looks like:

# app/controllers/contacts_controller.rb
class ContactsController < ApplicationController
  skip_before_action :authenticate_user!

  def new
    @contact = Contact.new
  end

  def create
    @contact = Contact.new(contact_params)

    if @contact.save
      redirect_to root_path, notice: "Contact was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  # Only allow a list of trusted parameters through.
  def contact_params
    params.require(:contact).permit(:email, :first_name, :last_name, :message)
  end
end

one last item to add is a logout link - this must be done since it is a delete and not a get - so just going to a link doesn’t work. So we can add: <%= current_user ? button_to("Logout: #{current_user.email}", destroy_user_session_path, method: :delete) : button_to("Login", new_user_session_path) %> or if you don’t want a sign-in just: <%= button_to("Logout: #{current_user.email}", destroy_user_session_path, method: :delete) if current_user %>

NOTE: most devise redirects are broken due to the way Turbo catches them. One fix is to use: <%= link_to "Sign out", destroy_user_session_path, data: { turbo_method: :delete } %> or with the following format: <%= link_to "Log Out", destroy_user_session_path, 'data-turbo-method': :delete %> instead of: <%= link_to "Sign out", destroy_user_session_path, method: delete %> Another fix is to use button_to i.e. button_to("Logout: #{current_user.email}", destroy_user_session_path, method: :delete) instead of link_to - which just sends a GET verb instead of a DELETE. link_to("Logout: #{current_user.email}", destroy_user_session_path, method: :delete)

NOTE: if any Devise or other link is getting messed up, adding: , data: { turbo_method: :delete } or , 'data-turbo-method': :delete to you link is likely to fix the problem.

# app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>SecuredNamespace</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <p class="notice"><%= notice %></p>
    <p class="alert"><%= alert %></p>
    <%= current_user ? button_to("Logout: #{current_user.email}", destroy_user_session_path, method: :delete) : link_to("Signin", new_user_session_path) %>
    <%= yield %>
  </body>
</html>

now test and be sure that when you login you can see the admin pages and when you are not logged in you cannot see them (& that you still have access to the landing page/root page)

git add .
git commit -m "admin page is restricted"

I’ll add this code to the repo and let the reader try this independently.

Separating Users and Admins

If you would like multiple levels of access we can add a roles attribute to our users.

let’s make a user migration to add an array of roles to our user with the default rule as a user and admin as an option.

rails g migration add_roles_to_user roles:text

now we need to adjust our migration a bit to tell postgressql to accept an array - change: add_column :users, :roles, :text to add_column :users, :roles, :text, array: true, default: ['user'] now the migration should look like:

class AddRolesToUser < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :roles, :text, array: true, default: ['user']
  end
end

this will make sure our existing users are a user and we can now make a new admin account.

now let’s migrate:

bin/rails db:migrate

let’s sanitize the user roles by adding normalizes :roles, with: -> roles { roles.map { |r| r.blank? ? 'user' : r }.uniq } to the user model so that in now looks like

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

  normalizes :email, with: -> email { email.downcase.strip }
  normalizes :roles, with: -> roles { roles.map { |r| r.blank? ? 'user' : r }.uniq }
end

now we can create a new admin user from the Command line using:

User.create!({:email => "admin@example.com", :roles => ["admin"], :password => "987654321-lkjhg", :password_confirmation => "987654321-lkjhg" })

now lets create a new admin application controller to user only admins have access:

# app/controllers/admin/application_controller.rb
class Admin::ApplicationController < ApplicationController
  before_action :authenticate_admin!

  private

  def authenticate_admin!
    # if not logged will be redirected to login page
    authenticate_user!
    # if not an admin will be redirected to root page
    redirect_to root_path, alert: 'not authorized' unless current_user.roles.include?('admin')
  end
end

Now any controller that inherits from Admin::ApplicationController will requite authentication!

so now we can adjust existing admin controller from class Admin::ContactsController < ApplicationController to: class Admin::ContactsController < Admin::ApplicationController

thus the controller now now looks like:

# app/controllers/admin/contacts_controller.rb
class Admin::ContactsController <  Admin::ApplicationController
  before_action :set_admin_contact, only: %i[ show edit update destroy ]

  def index
    @admin_contacts = Contact.all
  end

  def show
  end

  def new
    @admin_contact = Contact.new
  end

  def edit
  end

  def create
    @admin_contact = Contact.new(admin_contact_params)

    respond_to do |format|
      if @admin_contact.save
        format.html { redirect_to admin_contact_url(@admin_contact), notice: "Contact was successfully created." }
        format.json { render :show, status: :created, location: @admin_contact }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @admin_contact.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @admin_contact.update(admin_contact_params)
        format.html { redirect_to admin_contact_url(@admin_contact), notice: "Contact was successfully updated." }
        format.json { render :show, status: :ok, location: @admin_contact }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @admin_contact.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @admin_contact.destroy!

    respond_to do |format|
      format.html { redirect_to admin_contacts_url, notice: "Contact was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_admin_contact
    @admin_contact = Contact.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def admin_contact_params
    params.require(:contact).permit(:email, :first_name, :last_name, :message)
  end
end

be sure you test that all this works as expected and commcontactsit:

git add .
git commit -m "add roles to allow for other namespaces"

now you can also make controllers & resources available within additional namespaces beyond just admin and provide different levels of access.

USER ADMIN PANEL

Now that we have admin and user roles - we can follow most of the same steps we used for contacts to build an admin page for users. We will start with:

rails g scaffold_controller admin/user

Here I will only provide a few notes for the differences that are a little tricky:

  1. roles is an array so we need to use strong params differently in this case we need to tell rails to expect an array by using roles: [] so the file looks like:
# app/controllers/admin/users_controller.rb
  def admin_user_params
    params.require(:user).permit(:email, roles: [])
  end
  1. the form field for roles is a multi-select so :multiple => true is important - so the _form needs to use something like:
# app/views/admin/users/_form.html.erb
  <div>
    <%= form.label :roles, style: "display: block" %>
    <%= form.select :roles,
                    ['admin', 'user'],
                    :prompt => "Select Roles",
                    :multiple => true
    %>
  </div>

If any of this is still confusing you can refer to the repo with a full working solution at: https://github.com/btihen-dev/rails_secured_namespace

RESOURCES

Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature