One of the challenges that’s come up repeatedly in the Rails app I’m building is managing access to features effectively. With multiple user roles and types and a growing set of capabilities (thanks in part to easier access to coding tools) I needed a way to control who sees what, without constant code deploys or my fairly artisanal DIY workarounds.
Remember, I’m an Ops person writing code so I probably shouldn’t be trusted with the really hard stuff 🙂
That said, I went the custom route. It worked well enough at first, but as the app evolved, the limitations became clear: no easy percentage rollouts, no sophisticated targeting, and no straightforward path to richer observability. I was coding in rigidity by design.
That’s when I started looking at OpenFeature, the CNCF-backed open standard for feature flagging.
Here’s how I migrated from my custom setup to OpenFeature, and keeping everything backward compatible along the way. If you’re in a similar spot with your own app, hopefully this gives you some practical ideas.
The Initial Requirements
The core needs were pretty straightforward:
- Store feature permissions on a per-user basis
- Check access in controllers and views
- Automatic full access for admins
- Default enablement for core features
- Simple way to add new features (I’m already at 20 and counting!)
Nothing too advanced, but important for a clean user experience.
My Custom Implementation
I started with a simple, self-built approach that I’ve used for many other apps. It’s not too robust but I try to do it with no external services. Trying to just lean on Rails and PostgreSQL doing the heavy lifting.
Storing Permissions
A JSONB array column on the users table:
class AddAllowedFeaturesToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :allowed_features, :jsonb, default: [], array: true
add_index :users, :allowed_features, using: :gin
end
end
JSONB plus a GIN index made queries fast and flexible. Yay!
The Logic Layer
This get’s put in as a concern:
module FeatureAccess
extend ActiveSupport::Concern
AVAILABLE_FEATURES = {
"companies" => "Companies",
"competitive" => "Competitive Intelligence",
"partnerships" => "Partnerships",
# ...and more stuff I don't want to type out here...but you get where it's going
}.freeze
DEFAULT_FEATURES = ["companies"].freeze
def has_feature_access?(feature)
return true if admin?
return true if feature == "home"
return true if DEFAULT_FEATURES.include?(feature)
allowed_features.is_a?(Array) && allowed_features.include?(feature.to_s)
end
end
Straightforward and easy to work through the logic to me.
Protecting Controllers
A little before_action concern goes a long way:
module FeatureAccessControl
extend ActiveSupport::Concern
included do
before_action :check_feature_access
end
private
def check_feature_access
return unless authenticated?
return if current_user&.admin?
feature = controller_name
unless current_user&.has_feature_access?(feature)
redirect_to root_path, alert: "You don't have access to this feature."
end
end
end
Just include it where needed. Flavor to taste.
In my Views
Conditional rendering logic is pretty simple:
<% if current_user&.has_feature_access?('competitive') %>
<%= link_to "Competitive Intelligence", competitive_path %>
<% end %>
It performs fine, is easy to extend, and did what I needed it to do.
But over time, the shortcomings added up. No rollouts, no targeting beyond basic lists, and this leaves me locked into my own implementation.
Why Choose OpenFeature?
OpenFeature caught my eye as a vendor-agnostic standard (CNCF project), with a consistent API. Looking at who’s using it, there is clearly support for advanced use cases like evaluation contexts for targeting. The big win: write it once, swap providers later if needed (e.g. LaunchDarkly, Flagsmith, or even a simple custom backend…like I tend to lean towards).
Coming from an ops background, I appreciate something that has standards, can reduce lock-in, and has good support and docs for me to be able to keep building confidently.
The Migration: Keeping It Safe and Gradual
I wanted to introduce OpenFeature without disrupting the existing flow. Full backward compatibility was a hard requirement because I needed to be able to back out if something didn’t work. I figured that while I was at it I’d build it to support the existing method for fun.
Adding the SDK
# Gemfile
gem "openfeature-sdk"
Bridging with a Custom Provider
First thing is to mirror the existing logic that’s working:
class DatabaseFeatureProvider
def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
user_id = extract_user_id(evaluation_context)
return default_value unless user_id
user = User.find_by(id: user_id)
return default_value unless user
return true if user.admin?
return true if FeatureAccess::DEFAULT_FEATURES.include?(flag_key)
user.allowed_features.include?(flag_key.to_s)
end
private
def extract_user_id(evaluation_context)
evaluation_context&.instance_variable_get(:@fields)&.dig("user_id")
end
end
Exact same behavior, just through the OpenFeature interface.
Configuration
# config/initializers/openfeature.rb
OpenFeature::SDK.configure do |config|
config.set_provider(DatabaseFeatureProvider.new)
end
Updating the Access Check
Routed through OpenFeature but kept the public method identical, with fallback:
def has_feature_access?(feature)
begin
client = OpenFeature::SDK.build_client(evaluation_context: openfeature_context)
client.fetch_boolean_value(flag_key: feature.to_s, default_value: false)
rescue => e
Rails.logger.warn "OpenFeature fallback triggered: #{e.message}"
has_feature_access_direct?(feature) # Original direct check
end
end
private
def openfeature_context
OpenFeature::SDK::EvaluationContext.new(
"user_id" => id,
"admin" => admin?,
"email_domain" => email_address.split("@").last
)
end
No changes needed elsewhere in the codebase.
What this Gives Me
- Flexibility – Ready to switch providers without touching app code.
- Rich Context – Already passing user attributes—sets us up for targeting by domain, ID, etc.
- Advanced Features Ahead – Percentage rollouts, A/B testing, time-based rules, etc.
Quick example of a potential 50% rollout:
if fields["email_domain"] == "example.com"
return (fields["user_id"] % 2 == 0)
end
- Future Managed Options – Dashboards, analytics, and collaborative flag management when we outgrow the custom provider.
Handling the Risks
The SDK is lightweight, performance is unchanged (same DB queries), and the fallback gave me control during the transition. A few small gotchas (like Zeitwerk filename matching…my bad) were easy fixes with the docs, thankfully.
Keeping Support for the “Old Way”
Backward compatibility made this migration lower stress for me. Starting with a provider that replicates the old behavior was a must-have, as that was the fallback. Thorough testing and clear documentation rounded it out nicely.
If your custom flags are leaving you with limitations or you just want to standardize on an open architecture, OpenFeature is worth a look. It’s been a solid upgrade for me, opening up more control without the usual migration pain.
Resources to help you learn more:
Enjoy!