Simple role-based access control in Ruby

Published by Twain Taylor on May 13, 2025
Simple role-based access control in Ruby

Managing access control in Ruby applications starts simple. Pick a few boolean flags, some basic role checks, and you're off to the races. But as your application grows, these quick fixes multiply into a dense maze of if-else statements scattered throughout your codebase. Each new feature adds complexity, and maintaining permissions becomes increasingly challenging. You can't scale these fixes. Yet, your authorization system forms the backbone of application security. Given how every request passes through it, their efficiency directly impacts user experience.

Role-Based Access Control (RBAC) offers a structured way to handle these challenges. It aligns perfectly with Ruby's elegant syntax and conventions while laying sturdy grounds for access management. In this post, we'll explore how we can implement RBAC in Ruby, starting with basic patterns and building up to a production-ready solution.

Authorization evolution and RBAC

The evolution of authorization in Ruby applications reveals common patterns that developers encounter as their applications scale. At its core, RBAC emerged as a standardized approach to manage access rights through role assignments rather than individual permissions.

Here's what it might look like when setting flags in the database:

class User < ApplicationRecord
    def admin?
        role == 'admin'
    end

    def can_edit?
        role == 'editor' || admin?
    end
end

And like we said, these checks multiply across your codebase. Each new feature brings more conditions, more edge cases, and more technical debt. Your test suite grows unwieldy, and adding new roles becomes a maintenance nightmare. It's not a prediction, but the reality we live in.

Instead of sprinkling permission checks throughout your code, RBAC brings sanity to this chaos through clearly defined roles and permissions:

class User < ApplicationRecord
    def has_permission?(action, resource)
        roles.any? { |role| role.can_perform?(action, resource) }
    end
end

class Role < ApplicationRecord
    def can_perform?(action, resource)
        permissions.exists?(action: action, resource: resource)
    end
end

With this structured approach there are several clear benefits to be seen:

  • Roles map directly to job functions,
  • Permissions are managed in one place,
  • Adding new roles doesn't require code changes, and
  • Testing becomes more straightforward

In a more practical scenario, for apps in production, even this improved structure faces challenges. In fact, Ruby's ecosystem provides several approaches to authorization. Gems like CanCanCan and Pundit have served the community well, offering different philosophies to permission management. While CanCanCan centralizes abilities in a single file, Pundit takes an object-oriented approach with individual policy objects. These solutions work wonderfully for standard use cases. But, think again. As applications grow more complex, teams often face the hard fact that they have no flexibility. That's where we find ourselves looking for a dedicated authorization system.

From basic Ruby RBAC to scalable authorization

While basic RBAC implementations work for smaller applications, production systems face increasingly complex challenges. As your application scales, each permission check triggers database queries, adding latency to every request. Teams often turn to caching frequently accessed permissions to improve performance, but this introduces its own set of complexities around cache invalidation and consistency. The challenge multiplies as teams grow. You'll find yourself coordinating managing and versioning authorization policies with other team members, especially when audit requirements demand comprehensive logging of every access decision.

Consider an e-commerce platform where customer service representatives handle order refunds. The business rules seem simple at first: CSRs can refund orders within 30 days, under $1,000, and only for customers in their region. Senior reps shall have additional privileges. 

Let's take a look at the implementation:

class Order
    def refundable_by?(user)
        return true if user.admin?
        if user.customer_service?
            return false if amount > 1000
            return false if created_at < 30.days.ago
            return true if user.senior_rep?
            return user.region == customer.region
        end
        false
    end
end

Now, there are several problems with the above implementation. First, your business logic is tightly coupled with authorization rules. When refund policies change (and they will), you'll need to modify and redeploy your application code. Second, testing becomes increasingly complex as you add more edge cases. Said the other way around, every new business rule adds complexity and your test suite will grow:

RSpec.describe Order do
    context "refund permissions" do
        let(:order) { create(:order, amount: 500) }
        let(:cs_rep) { create(:user, :customer_service, region: "APAC") }
        it "prevents refunds across regions" do
            order.update(customer: create(:customer, region: "EMEA"))
            expect(order.refundable_by?(cs_rep)).to be false
        end
    end
end

As your application scales, you'll need to consider:

  • Audit trails for compliance
  • Performance impact of multiple database queries
  • Caching strategies for frequently accessed permissions
  • Version control for authorization policies

This is where a dedicated authorization service becomes invaluable. Cerbos, an open-source authorization layer, solves these challenges by externalizing access control into declarative policies. Unlike traditional RBAC gems we discussed earlier, Cerbos enables dynamic permission management without code changes. That is, policies update instantly across all services. Its distributed Policy Decision Points handle thousands of requests per second with sub-millisecond latency, while built-in audit logs satisfy SOC2 and ISO27001 requirements.

By separating authorization from business logic, you can implement complex rules like regional restrictions or time-based access through simple YAML configurations, transforming authorization from a development bottleneck to a strategic asset. Take a look at this:

# config/initializers/cerbos.rb
require 'cerbos'
CERBOS_CLIENT = Cerbos::Client.new(
    host: ENV['CERBOS_HOST'],
    playground: false,
    tls: true
)
# app/controllers/application_controller.rb
def authorize_action!(resource, action)
    principal = {
        id: current_user.id,
        roles: current_user.roles,
        attributes: {
            department: current_user.department,
            region: current_user.region
        }
    }
    resource_context = {
        kind: resource.class.name.downcase,
        id: resource.id,
        attributes: resource.authorization_attributes
    }
    unless CERBOS_CLIENT.is_allowed?(principal, resource_context, action)
        raise NotAuthorizedError
    end
end

Taking authorization to production

Any capable authorization system requires careful attention to production considerations. Here are a few best practices you can keep in mind:

Performance and monitoring

Implement smart caching strategies for frequently accessed permissions to keep your application responsive. Here's an example of Redis caching with TTL:

Rails.cache.fetch("user_#{user.id}_permissions", expires_in: 5.minutes) do
    user.permissions.pluck(:action, :resource)
end

You should also set up comprehensive monitoring, keeping an eye on latency percentiles (p95/p99), and also set alerts for authorization bottlenecks.

Security practices

Schedule regular audits of role assignments to maintain system integrity and compliance.

task :audit_roles => :environment do
    User.includes(:roles).find_each do |user|
        next if user.roles.any?
        SecurityAlert.create!(user: user, message: "Orphaned account")
    end
end

It's also highly recommended to enforce GitOps practices. That way you can store policies in version control with required peer reviews before deployment. Cerbos Hub allows you to test, validate, and deploy policies at scale, the GitOps way.

This will also ensure consistent behavior across all environments while maintaining a clear audit trail of changes.

Team enablement

Create clear, comprehensive guides that help teams understand the permission structure and maintain it effectively. A living documentation using Swagger UI for API endpoints and permission requirements is a good idea.

RSpec.describe "Refund Policies" do
    let(:policy) { Cerbos.load_policy("refund") }
    it "allows regional CSRs under $1000" do
        request = build_request(region: "APAC", amount: 500)
        expect(policy.allowed?(request)).to be true
    end
end

Parting thoughts

Authorization requirements evolve as applications grow. While traditional approaches serve basic needs, modern applications demand sophisticated solutions that can handle complex business rules while maintaining performance and security. Cerbos steps in as that robust solution, separating authorization logic from application code and providing the scalability modern applications need.

If you want to dive deeper into implementing and managing authorization, join one of our engineering demos or check out our in-depth documentation.

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team