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.
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:
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.
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:
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
Any capable authorization system requires careful attention to production considerations. Here are a few best practices you can keep in mind:
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.
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.
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
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
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.