Admin Authorisation

Posted: June 14, 2022

phil reynolds

head of software development

Users, Organisations and Admin Authorisation at Ryalto

Here at Ryalto, we’re working on a better way to manage the relationship between users and organisations.

The old way

Previously, a user belonged to an organisation and an organisation had many users. We also had a separate model for admins, which also belong to an organisation. The actual implementation was a good deal more complicated than that, but I’m not going down that rabbit hole today!

We had a requirement from a client that users needed to be able to switch between organisations. We look the user-organisation relationship from many one-to-many to many-to-many. The initial implementation of this was a semantic link between user accounts to preserve the hierarchy. A user switched organisations by switching user accounts.

A new way for Users <—> Organisations

When thinking about our rebuild I wanted to model our system more closely to the real world. A user has many organisations and an organisation has many users. This was done through a user-organisation model.

# app/models/user.rb
# User Model
class User < ApplicationRecord
  has_many :user_organisations
  accepts_nested_attributes_for :user_organisations, allow_destroy: true
  has_many :organisations, through: :user_organisations
end

# app/models/organisation.rb
# Organisation Model
class Organisation < ApplicationRecord
  has_many :user_organisations
  has_many :users, through: :user_organisations
end

# app/models/user_organisation.rb
# UserOrganisation Model
# This is the model which connects users to organisations.
class UserOrganisation < ApplicationRecord
  belongs_to :user
  belongs_to :organisation
end

With this approach, the UserOrganisation model stores all the information about a users relationship with a particular organisation. Their organisation verification status, whether their account is enabled, any elevated privileges that they have and which is their “current” organisation.

The current organisation is important, as we want to scope the users activity, and pretty much everything else they see in the application based on that “current” organisation.

The big thing that is missing from this proper validation that a user must have exactly one organisation as their current organisation.

We get the user’s current organisation by calling “find_by” on the user organisation. Find by is great here, as if there should end up being multiple organisations with the “current” boolean set to true, then it will return the first one only.

# app/models/user.rb
# User Model
def current_user_organisation
  user_organisations.find_by(current: true)
end

Then we switch organisation by first setting all of the current booleans to false, and updating the one that the user is switching to.

# app/models/user.rb
# User Model
def switch_organisation(organisation)
  user_organisations.update_all(current: false)
  user_organisations.find_by(organisation:).update(current: true)
end

To call this method we have a route and controller action.

# config/routes.rb
  authenticated :user do
      # ...
    devise_scope :user do
      patch '/switch_organisation/:organisation', to: 'users/registrations#switch_organisation', as: 'switch_organisation'
    end
  end

module Users
  # app/controllers/users/registrations_controller.rb
  class RegistrationsController < Devise::RegistrationsController
    def switch_organisation
      current_user.switch_organisation(params[:organisation])
      redirect_back(fallback_location: root_path)
    end
  end
end

<h3>Switch Organisation</h3>
<% @organisations.each do |org| %>
  <% next if org.current %>

  <%= button_to org.organisation.name, switch_organisation_path(org.organisation), method: :patch, class: 'btn link-warning' %>
<% end %>

Managing Authorisation for Elevated Privileges

Ryalto is a multi-functional app. For each section of the app, an organisation might want to set one or many users to be able to manage it, and one user might need privileges for multiple sections of the app. The simplest approach was to store a boolean in the UserOrganisation table for each admin area.

      t.boolean :organisation_admin, null: false, default: false
      t.boolean :news_feed_admin, null: false, default: false
      t.boolean :shift_admin, null: false, default: false

We didn’t want to create a separate admin user type, in order to keep things as close as possible to the real world. A user can have some admin rights at one organisation, but be a part of a separate organisation where they don’t have any elevated privileges. We didn’t want users to have to log out of their user account and log back in as an admin.

The user model can return whether or not a user is particular admin for their current organisation.

# app/models/user.rb
# User Model
class User < ApplicationRecord
  has_many :user_organisations
  accepts_nested_attributes_for :user_organisations, allow_destroy: true
  has_many :organisations, through: :user_organisations

  def organisation_admin?
    current_user_organisation.organisation_admin
  end

  def shift_admin?
    current_user_organisation.shift_admin
  end

  def news_feed_admin?
    current_user_organisation.news_feed_admin
  end

  private

  def current_user_organisation
    user_organisations.find_by(current: true)
  end
end

There were a few different options for how to manage this authorisation for the controller actions. We’re scoping the admin actions under an admin module, which a separate controller for each set of admin functionality. There were two initial approaches considered: verify authorisation in each controller, or extract it to the application controller.

In each controller it could look like this:

module Admin
  # app/controllers/admin/organisations_controller.rb
  class OrganisationsController < ApplicationController

      # methods

       private

        def authorise_admin
            return if current_user.organisation_admin?

            redirect_back(fallback_location: root_path, flash: { error: "You don't have permission to do that action." })
        end
    end
end

Fairly simple, fairly clean, but it doesn’t feel very DRY.

In the application controller:

module Admin
    # app/controllers/admin/organisations_controller.rb
    class OrganisationsController < ApplicationController
        before_action :authorise

        # methods

        private

        def authorise
            authorise_admin("organisation")
        end
    end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
    # other stuff

    protected

    def authorize_admin(type)
        case type
        when "organisation"
          return if current_user.admin?
        when "news_feed"
          return if current_user.news_feed_admin?
        when "shift"
          return if current_user.shift_admin?
        end

    	redirect_back(fallback_location: root_path, flash: { error: "You don't have permission to do that action." })
  end
end

This feels better, but is quite verbose. The big draw back with this those is that you end up including the admin authorisation in every area of the app - which we don’t necessarily want. It’s also muddling the separation of concerns slightly.

Another option would be to have each specific admin controller inherit from a parent admin controller which handles the authorisation and any other shared methods. This felt like a good approach, but then suddenly:

Helpers to the Rescue!

Extracting the authorisation to a helper allows use to include it where ever we needed, keep things DRY and doesn’t muddle SRP.

The other really cool thing we’ve done is writing a single method for all of the separate authorisations. The limitation is that the name of the admin method on the user model must be the singular version of the controller that it’s being called from. This is what we should be trying to do anyway to keep things readable.

So now, in each of our admin controllers we just include AdminHelper and the admin helper is just this:

# app/helpers/admin_helper.rb
# Methods which are shared between admin controllers
module AdminHelper
  def authorise_admin
    return if current_user.public_send("#{controller_name.singularize}_admin?")

    redirect_back(fallback_location: root_path, flash: { error: "You don't have permission to do that action." })
  end
end

Conclusion

The solution we’ve ended up with relies on a bit of extra convention. Future developers working on this section have to be considered with their naming conventions, but we have a solution that is DRY, maintainable and extensible! Exactly what we’re looking for!

Can you think of any way we could have implemented this better?