Skip to content

Commit d7c6670

Browse files
dmitrytragerclaude
andcommitted
Add feature flags system with beacons flag for targeted rollout
Credentials-based feature flags adapted from Rails 8.2 pattern for 8.1, using Rails.application.credentials.dig instead of Rails.app.creds.option. Supports per-user targeting via allowed_emails, ENV overrides, and request-scoped toggles for testing. Beacons feature gated to admin@skillrx.org. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 36758e0 commit d7c6670

File tree

5 files changed

+217
-0
lines changed

5 files changed

+217
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ db/structure.sql
4343

4444
/app/assets/builds/*
4545
!/app/assets/builds/.keep
46+
47+
# Ignore key files for decrypting credentials and more.
48+
/config/*.key
49+

app/models/current.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class Current < ActiveSupport::CurrentAttributes
22
attribute :session
33
attribute :beacon
4+
attribute :feature_overrides
45
delegate :user, to: :session, allow_nil: true
56
end

app/models/feature_flags.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
module FeatureFlags
2+
extend self
3+
4+
def enabled?(flag_name, user: nil)
5+
override = Current.feature_overrides&.dig(flag_name.to_sym)
6+
return override unless override.nil?
7+
8+
env_value = ENV["FEATURE_#{flag_name.to_s.upcase}"]
9+
return cast_boolean(env_value) unless env_value.nil?
10+
11+
config = feature_config(flag_name)
12+
return false if config.nil?
13+
return cast_boolean(config) unless config.respond_to?(:dig)
14+
15+
return true if cast_boolean(config[:enabled])
16+
17+
user_allowed?(config, user)
18+
end
19+
20+
def disabled?(flag_name, user: nil)
21+
!enabled?(flag_name, user: user)
22+
end
23+
24+
def enable!(flag_name)
25+
(Current.feature_overrides ||= {})[flag_name.to_sym] = true
26+
end
27+
28+
def disable!(flag_name)
29+
(Current.feature_overrides ||= {})[flag_name.to_sym] = false
30+
end
31+
32+
def with(flag_name, value)
33+
old = Current.feature_overrides&.dig(flag_name.to_sym)
34+
value ? enable!(flag_name) : disable!(flag_name)
35+
yield
36+
ensure
37+
if old.nil?
38+
Current.feature_overrides&.delete(flag_name.to_sym)
39+
else
40+
Current.feature_overrides[flag_name.to_sym] = old
41+
end
42+
end
43+
44+
private
45+
46+
def feature_config(flag_name)
47+
Rails.application.credentials.dig(:features, flag_name.to_sym)
48+
end
49+
50+
def user_allowed?(config, user)
51+
return false if user.nil?
52+
53+
allowed_emails = config[:allowed_emails]
54+
return false if allowed_emails.blank?
55+
56+
allowed_emails.include?(user.email)
57+
end
58+
59+
def cast_boolean(value)
60+
ActiveModel::Type::Boolean.new.cast(value) || false
61+
end
62+
end

config/credentials.yml.enc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
yjspzEXQf5fPuCTqkmnahCuQrqf3YDn0Tk1qyro/FP2Tp/Wz9P0CGt2+kkRBpl5ozR+vf6zLODRaFThjJgOazYqS6bWApMzo1jUn6kTPLTY9AOUF4VOxUajbZpwXUDJL/nG0sdg3y+dv3g6/nyCgu6BNndO1BJBezgFRoocUFfKyAQXkZTs7c0KCIsLjoJlK8pY+l6d5jowQqmaB6gJVTCBXDAV5Jr3n/wSM2gzQuVEup22em/sbRCXXR2GPdHgfXtthEMYh5ePOwFGqFimDymO4ZqrnTNHzEiO1zJdeAjdGjg0mNarFbefMtaLpKbnxeqaObm2tOPQVhEIGXOhIsB+a5oLJjnXuDSe5+UoYIBXHygRxx8p6ktqWovyIqDLWB8ojDW/YwrqXy4qoj8SACvg4tKQcKRhFJGJwXxzN2bu3G5yZVZQ75uvSwf0FEOATBEMqOnlvjzPZsvEKQnPCxqCPMf54R8YnNVEbdv+DWtWJlmSJrA6p+/jQ7ZgXmgyIWl/merILgSaCXcbMPqCoVwl1wLkdFjab5d/e6/xz2i/giPHv31o1bvl8RGFhZOz2DeDnqiYMv3GZ/f3oDeiMv/8LamSkQYPr3DqtFmms1UA=--oKBd19vdWhjbzt0B--ppPQiT/cJjaOxgDtNZyU1A==

spec/models/feature_flags_spec.rb

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
require "rails_helper"
2+
3+
RSpec.describe FeatureFlags do
4+
after { Current.feature_overrides = nil }
5+
6+
describe ".enabled?" do
7+
context "with request-scoped override" do
8+
it "returns true when flag is overridden to true" do
9+
described_class.enable!(:beacons)
10+
11+
expect(described_class.enabled?(:beacons)).to be true
12+
end
13+
14+
it "returns false when flag is overridden to false" do
15+
described_class.disable!(:beacons)
16+
17+
expect(described_class.enabled?(:beacons)).to be false
18+
end
19+
20+
it "takes precedence over credentials" do
21+
allow(Rails.application.credentials).to receive(:dig)
22+
.with(:features, :beacons).and_return(true)
23+
24+
described_class.disable!(:beacons)
25+
26+
expect(described_class.enabled?(:beacons)).to be false
27+
end
28+
end
29+
30+
context "with ENV override" do
31+
it "returns true when ENV is set to true" do
32+
allow(ENV).to receive(:[]).and_call_original
33+
allow(ENV).to receive(:[]).with("FEATURE_BEACONS").and_return("true")
34+
35+
expect(described_class.enabled?(:beacons)).to be true
36+
end
37+
38+
it "returns false when ENV is set to false" do
39+
allow(ENV).to receive(:[]).and_call_original
40+
allow(ENV).to receive(:[]).with("FEATURE_BEACONS").and_return("false")
41+
42+
expect(described_class.enabled?(:beacons)).to be false
43+
end
44+
end
45+
46+
context "with boolean credential" do
47+
it "returns true when credential is true" do
48+
allow(Rails.application.credentials).to receive(:dig)
49+
.with(:features, :my_flag).and_return(true)
50+
51+
expect(described_class.enabled?(:my_flag)).to be true
52+
end
53+
54+
it "returns false when credential is false" do
55+
allow(Rails.application.credentials).to receive(:dig)
56+
.with(:features, :my_flag).and_return(false)
57+
58+
expect(described_class.enabled?(:my_flag)).to be false
59+
end
60+
end
61+
62+
context "with hash credential and allowed_emails" do
63+
before do
64+
allow(Rails.application.credentials).to receive(:dig)
65+
.with(:features, :beacons)
66+
.and_return({ enabled: false, allowed_emails: ["admin@skillrx.org"] })
67+
end
68+
69+
it "returns true for a user whose email is allowed" do
70+
user = build(:user, email: "admin@skillrx.org")
71+
72+
expect(described_class.enabled?(:beacons, user: user)).to be true
73+
end
74+
75+
it "returns false for a user whose email is not allowed" do
76+
user = build(:user, email: "other@example.com")
77+
78+
expect(described_class.enabled?(:beacons, user: user)).to be false
79+
end
80+
81+
it "returns false when no user is provided" do
82+
expect(described_class.enabled?(:beacons)).to be false
83+
end
84+
end
85+
86+
context "with globally enabled hash credential" do
87+
before do
88+
allow(Rails.application.credentials).to receive(:dig)
89+
.with(:features, :beacons)
90+
.and_return({ enabled: true, allowed_emails: ["admin@skillrx.org"] })
91+
end
92+
93+
it "returns true regardless of user" do
94+
expect(described_class.enabled?(:beacons)).to be true
95+
end
96+
end
97+
98+
context "with unknown flag" do
99+
it "returns false" do
100+
allow(Rails.application.credentials).to receive(:dig)
101+
.with(:features, :unknown).and_return(nil)
102+
103+
expect(described_class.enabled?(:unknown)).to be false
104+
end
105+
end
106+
end
107+
108+
describe ".disabled?" do
109+
it "returns the inverse of enabled?" do
110+
allow(Rails.application.credentials).to receive(:dig)
111+
.with(:features, :beacons).and_return(true)
112+
113+
expect(described_class.disabled?(:beacons)).to be false
114+
end
115+
end
116+
117+
describe ".with" do
118+
it "enables the flag within the block" do
119+
value_inside = nil
120+
121+
described_class.with(:beacons, true) do
122+
value_inside = described_class.enabled?(:beacons)
123+
end
124+
125+
expect(value_inside).to be true
126+
expect(described_class.enabled?(:beacons)).to be false
127+
end
128+
129+
it "disables the flag within the block" do
130+
described_class.enable!(:beacons)
131+
132+
value_inside = nil
133+
described_class.with(:beacons, false) do
134+
value_inside = described_class.enabled?(:beacons)
135+
end
136+
137+
expect(value_inside).to be false
138+
expect(described_class.enabled?(:beacons)).to be true
139+
end
140+
141+
it "restores the previous override after the block" do
142+
described_class.enable!(:beacons)
143+
144+
described_class.with(:beacons, false) { }
145+
146+
expect(Current.feature_overrides[:beacons]).to be true
147+
end
148+
end
149+
end

0 commit comments

Comments
 (0)