Authorization feels simple in the early stages of a Rails application.
You define a few roles.
You write some policy methods.
You add a handful of conditional checks.
It works.
In year one, that's usually enough.
The tension appears later.
Features expand. Roles multiply. Exceptions accumulate. A simple "admin vs user" distinction becomes a matrix of capabilities. What started as a few clean policy methods begins to encode assumptions about how permissions are stored and assigned.
And that's where the real problem surfaces.
Not complexity.
Coupling.
When your permission model changes, how much of your policy layer needs to change with it?
If the answer is "a lot", then the system isn't just complex. It's entangled.
In long-lived Rails applications, authorization strategies evolve. Direct permissions become group-based. Groups gain hierarchy. Multi-tenant boundaries appear. Auditing requirements emerge. The persistence model shifts to accommodate new realities.
Your policies shouldn't need to be rewritten every time that happens.
Authorization logic will grow. That's normal.
What matters is whether it grows inside clear boundaries - or across them.
The Hidden Coupling in Most Rails Authorization
In many Rails applications, authorization logic begins its life inside policy methods.
A policy might check a role directly:
def update?
user.admin? || user.manager?
end
Or it may reach into a permissions table:
def destroy?
user.permissions.exists?(name: "employees.destroy")
end
There's nothing inherently wrong with either approach. They're straightforward. They're readable. They solve the immediate problem.
The issue is structural.
In these examples, the policy method is doing more than expressing intent. It's also deciding how that intent is evaluated. It knows where permissions live. It knows how they're queried. It often knows how roles are represented.
Orchestration and decision logic become intertwined.
Over time, this pattern scales in ways that aren't immediately obvious. A few direct checks become dozens. Slight variations appear between policies. Edge cases introduce conditionals that reference specific tables or associations. The policy layer starts to mirror the persistence layer.
At that point, a change to the permission model isn't isolated. It ripples outward.
If you move from direct permissions to group-based RBAC, policy methods change.
If you introduce tenant scoping, policy methods change.
If you refactor roles into something more granular, policy methods change.
The policies were meant to answer a simple question: "Is this action allowed?"
Instead, they've become tightly coupled to how that answer is computed.
This isn't a critique of a specific authorization strategy. The problem is structure, repeatability, and maintainability.
The issue isn't RBAC. It's entanglement.
Principle 1: Authorization Strategies Should Be Replaceable
To untangle authorization, we need to separate three concerns that are often collapsed into one.
First, there is the policy engine. This is the orchestration layer. It decides when authorization is evaluated and which policy method is responsible for a given controller action.
Second, there is the authorization strategy. This is the decision logic. It answers the question: given a user and a context, is this allowed?
Third, there is the persistence model. This is a domain concern. It defines how roles, groups, permissions, and relationships are stored and associated.
These are not the same responsibility.
In many systems, they drift together over time. The policy method calls into a role check that assumes a specific table structure. The role check assumes a particular association model. The persistence layer becomes implicitly coupled to the policy surface.
That coupling rarely hurts in year one.
It becomes visible in year three.
Direct permissions become group-based.
Flat groups acquire hierarchy.
Single-tenant assumptions give way to tenant scoping.
Static roles evolve into dynamic, feature-level permissions.
None of these shifts are unusual. They're a sign that the system is growing.
But if your policies are written against a specific storage model, every structural change forces a policy rewrite. The orchestration layer becomes brittle because it knows too much.
That's unnecessary.
Policies should express intent.
Strategies should evaluate that intent.
The persistence model should serve the domain.
If those boundaries are respected, the authorization strategy can evolve without destabilising the policy surface.
Policies should not care how permissions are stored.
They should care only that a decision can be made.
Principle 2: Policies Should Orchestrate. Rules Should Decide.
If authorization strategies are meant to be replaceable, the structure of the policy layer has to support that.
This leads to a second principle: Policies should orchestrate. Rules should decide.
A policy sits at the controller boundary. Its responsibility is to map an action to an authorization outcome. It expresses intent.
When a controller calls update, the policy answers a single question: is this action allowed?
It shouldn't need to know how that answer is computed.
Instead, the policy should compose one or more rule objects - small, focused units of decision logic.
A rule encapsulates the strategy.
It receives the user, the context, and any options.
It returns a boolean.
Conceptually, a policy method becomes something like this:
authorized?([PermissionPolicyRule], permission: "employees.read")
The policy declares the required capability.
The rule determines whether the user satisfies it.
That separation is subtle, but it changes the system's behaviour over time.
The policy remains stable because it expresses intent at the level of the application. "This action requires employees.read." That statement doesn't change just because your group model changes.
The rule encapsulates the strategy. Today it might query direct permissions. Tomorrow it might traverse hierarchical groups. In a multi-tenant system, it might apply scoping logic. The policy does not need to know.
The persistence layer becomes an implementation detail of the rule.
This is what composability looks like in practice. Policies compose rules. Rules encapsulate strategies. Strategies rely on the domain model.
Each layer has a boundary.
When those boundaries are respected, authorization logic can grow without spreading across the system.
Demonstrating the Separation
This separation wasn't an abstract exercise.
It's the architectural pattern I wanted when building long-lived Rails systems.
I wanted a policy layer that stayed stable as the application evolved. I wanted authorization strategies that could change without forcing a rewrite of every policy method. And I didn't want the core engine to dictate how permissions were stored.
That's the motivation behind AccessForge.
AccessForge handles orchestration. It resolves policies from controllers. It maps actions to predicate methods. It provides a small surface for composing rule objects.
It does not dictate how your application makes authorization decisions. It ships with two default rules β always open and always closed. The rest is left to you. You provide rule classes based on your own domain model.
The strategy layer can then be implemented independently.
This is where AccessForge::Permissions fits. It's the first official extension - a permission-based rule built on top of the core engine.
The contract is deliberately minimal: If user.permissions exists, a decision can be evaluated.
Thatβs it.
Permissions may be assigned directly to users.
They may be granted through groups.
Groups may be hierarchical.
Permissions may carry metadata or tenant scoping.
The rule does not enforce a structure. It relies on a boundary.
If the persistence model evolves, the rule can evolve with it.
If a different authorization strategy is required, a different rule can replace it.
The policies remain unchanged.
This isn't abstraction for its own sake.
It's about keeping orchestration stable while allowing strategy and persistence to adapt to the domain.
Why Composability Beats Coupling
The distinction here isn't about elegance. It's about system properties.
Composability is not a stylistic preference. It's an architectural characteristic.
When rules are small and focused, they become composable. A policy can rely on a single rule. It can combine multiple rules. It can layer contextual checks without embedding storage assumptions.
Policies compose rules.
Rules encapsulate strategies.
Strategies depend on the domain model.
Each layer has a clear responsibility.
Because of that separation, strategies can evolve independently. A permission rule can change its internal query logic. It can introduce tenant scoping. It can traverse hierarchical group structures. It can even be replaced entirely.
The policy surface does not need to change.
Contrast that with coupling.
If a policy method directly references user.admin?, it is bound to a specific role model. If permission queries are embedded inside multiple policy methods, logic becomes duplicated. When the persistence structure changes, those assumptions must be hunted down and rewritten.
The system resists change because its boundaries are unclear.
This is where many Rails applications begin to accumulate accidental complexity. Not because the framework is limited, but because structural decisions were deferred.
Flexibility does not come from fewer boundaries.
It comes from well-defined ones.
When policies orchestrate and rules decide, the system gains a stable surface and a flexible interior. Authorization can grow in sophistication without spreading across the application.
Composability over coupling is not an aesthetic choice.
It's a way to keep long-lived systems evolvable.
What This Means in Year Three
In the early stages of a product, authorization feels contained.
By year three, it rarely is.
Features expand. Capabilities become more granular. What was once a single "manage employees" permission becomes read, write, export, approve, and archive. Different customer tiers require different access levels. Enterprise clients ask for custom role configurations.
In a SaaS environment, tenant boundaries become stricter. Data isolation requirements increase. Audit trails become mandatory. Internal teams grow, and multiple developers begin touching the authorization layer.
At that point, authorization is no longer a small concern. It's part of the system's core integrity.
If policies are tightly coupled to a specific role model or storage structure, each new requirement increases risk. Changes feel invasive. Refactors feel dangerous. Work slows down because the boundaries are unclear.
But if orchestration, strategy, and persistence are properly separated, growth becomes more predictable.
New permission structures can be introduced without destabilising policy definitions. Tenant scoping can be added inside rules without rewriting controllers. Audit logic can evolve alongside the strategy layer.
The surface remains stable while the interior adapts.
That stability is not about purity. It's about risk reduction.
Long-lived systems accumulate complexity. That's inevitable.
The goal is to ensure that complexity accumulates behind boundaries, not across them.
Authorization doesn't need to be clever.
It needs to remain evolvable.
That's the design goal behind AccessForge and its first extension.
Not cleverness.
Replaceability.
Top comments (0)