Skip to content

Commit 549f781

Browse files
authored
Add feature flag support via Flipper (#663)
## Status - Closes [#1101](RaspberryPiFoundation/digital-editor-issues#1101) - Related to RaspberryPiFoundation/editor-standalone#723 ## Points for consideration: ### Security - Adds a new admin panel at `/admin/flipper`. This uses a route constraint to deny access to non-admin users (based on session cookie). - Disabled feature flags are not shared with the client. However, at present all enabled features are shared with the client, regardless of whether they're expected to have any effect on the client. ### Performance - In this implementation, each School is a potential Actor (in Flipper terms). Flipper prefers to load feature data once per request and perform membership checks in memory to reduce the number of calls to the DB, but this can be less efficient if there are many Actors. Therefore, by default, there is a limit of 100 Actors per feature. See the [Flipper docs - Actors](https://www.flippercloud.io/docs/features/actors#limitations). - See also [Flipper docs - Performance](https://www.flippercloud.io/docs/optimization) ## What's changed? - Adds Flipper UI, allowing admin users to manage feature flags, and an endpoint that exposes enabled feature flags to the client. ## Steps to perform after deploying to production - Migration(?)
1 parent 86d2996 commit 549f781

10 files changed

Lines changed: 266 additions & 1 deletion

File tree

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ gem 'countries'
1414
gem 'email_validator'
1515
gem 'faker'
1616
gem 'faraday'
17+
gem 'flipper', '~> 1.3'
18+
gem 'flipper-active_record', '~> 1.3'
1719
gem 'github_webhook', '~> 1.4'
1820
gem 'globalid'
1921
gem 'good_job', '~> 4.3'
@@ -76,3 +78,5 @@ group :test do
7678
gem 'webdrivers'
7779
gem 'webmock'
7880
end
81+
82+
gem 'flipper-ui', '~> 1.3'

Gemfile.lock

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,18 @@ GEM
186186
ffi (1.17.2-arm64-darwin)
187187
ffi (1.17.2-x86_64-linux-gnu)
188188
fiber-storage (1.0.1)
189+
flipper (1.3.6)
190+
concurrent-ruby (< 2)
191+
flipper-active_record (1.3.6)
192+
activerecord (>= 4.2, < 9)
193+
flipper (~> 1.3.6)
194+
flipper-ui (1.3.6)
195+
erubi (>= 1.0.0, < 2.0.0)
196+
flipper (~> 1.3.6)
197+
rack (>= 1.4, < 4)
198+
rack-protection (>= 1.5.3, < 5.0.0)
199+
rack-session (>= 1.0.2, < 3.0.0)
200+
sanitize (< 8)
189201
fugit (1.11.2)
190202
et-orbi (~> 1, >= 1.2.11)
191203
raabro (~> 1.4)
@@ -481,6 +493,9 @@ GEM
481493
ffi (~> 1.12)
482494
logger
483495
rubyzip (3.0.2)
496+
sanitize (7.0.0)
497+
crass (~> 1.0.2)
498+
nokogiri (>= 1.16.8)
484499
sassc (2.4.0)
485500
ffi (~> 1.9)
486501
sassc-rails (2.1.2)
@@ -579,6 +594,9 @@ DEPENDENCIES
579594
factory_bot_rails
580595
faker
581596
faraday
597+
flipper (~> 1.3)
598+
flipper-active_record (~> 1.3)
599+
flipper-ui (~> 1.3)
582600
github_webhook (~> 1.4)
583601
globalid
584602
good_job (~> 4.3)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
class FeaturesController < ApiController
5+
def index
6+
school = current_user&.schools&.first
7+
8+
enabled_feature_keys = Flipper.features
9+
.select { |feature| Flipper.enabled?(feature.key, school) }
10+
.map(&:key)
11+
12+
render json: { enabled: enabled_feature_keys }
13+
end
14+
end
15+
end

config/initializers/flipper.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
Rails.application.configure do
4+
## Memoization ensures that only one adapter call is made per feature per request.
5+
## For more info, see https://www.flippercloud.io/docs/optimization#memoization
6+
# config.flipper.memoize = true
7+
8+
## Flipper preloads all features before each request, which is recommended if:
9+
## * you have a limited number of features (< 100?)
10+
## * most of your requests depend on most of your features
11+
## * you have limited gate data combined across all features (< 1k enabled gates, like individual actors, across all features)
12+
##
13+
## For more info, see https://www.flippercloud.io/docs/optimization#preloading
14+
# config.flipper.preload = true
15+
16+
## Warn or raise an error if an unknown feature is checked
17+
## Can be set to `:warn`, `:raise`, or `false`
18+
# config.flipper.strict = Rails.env.development? && :warn
19+
20+
## Show Flipper checks in logs
21+
# config.flipper.log = true
22+
23+
## Reconfigure Flipper to use the Memory adapter and disable Cloud in tests
24+
# config.flipper.test_help = true
25+
26+
## The path that Flipper Cloud will use to sync features
27+
# config.flipper.cloud_path = "_flipper"
28+
29+
## The instrumenter that Flipper will use. Defaults to ActiveSupport::Notifications.
30+
# config.flipper.instrumenter = ActiveSupport::Notifications
31+
end
32+
33+
Flipper.configure do |config|
34+
## Configure other adapters that you want to use here:
35+
## See http://flippercloud.io/docs/adapters
36+
# config.use Flipper::Adapters::ActiveSupportCacheStore, Rails.cache, expires_in: 5.minutes
37+
end
38+
39+
## Register a group that can be used for enabling features.
40+
##
41+
## Flipper.enable_group :my_feature, :admins
42+
##
43+
## See https://www.flippercloud.io/docs/features#enablement-group
44+
#
45+
# Flipper.register(:admins) do |actor|
46+
# actor.respond_to?(:admin?) && actor.admin?
47+
# end

config/routes.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
mount GoodJob::Engine => 'good_job'
66
resources :components
77

8+
mount Flipper::UI.app(Flipper) => '/flipper',
9+
constraints: AdminSessionConstraint.new
10+
811
resources :projects do
912
delete :images, on: :member, action: :destroy_image
1013
end
@@ -87,6 +90,8 @@
8790
resources :school_import_jobs, only: %i[show]
8891

8992
post '/google/auth/exchange-code', to: 'google_auth#exchange_code', defaults: { format: :json }
93+
94+
resources :features, only: %i[index]
9095
end
9196

9297
resource :github_webhooks, only: :create, defaults: { formats: :json }
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class CreateFlipperTables < ActiveRecord::Migration[7.2]
2+
def up
3+
create_table :flipper_features do |t|
4+
t.string :key, null: false
5+
t.timestamps null: false
6+
end
7+
add_index :flipper_features, :key, unique: true
8+
9+
create_table :flipper_gates do |t|
10+
t.string :feature_key, null: false
11+
t.string :key, null: false
12+
t.text :value
13+
t.timestamps null: false
14+
end
15+
add_index :flipper_gates, [:feature_key, :key, :value], unique: true, length: { value: 255 }
16+
end
17+
18+
def down
19+
drop_table :flipper_gates
20+
drop_table :flipper_features
21+
end
22+
end

db/schema.rb

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
class AdminSessionConstraint
4+
def matches?(request)
5+
current_user = request.session[:current_user]
6+
return false unless current_user
7+
8+
User.new(current_user).admin?
9+
end
10+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Flipper' do
6+
let(:school) { create(:school) }
7+
let(:student) { create(:student, school:) }
8+
let(:teacher) { create(:teacher, school:) }
9+
let(:admin_user) { create(:admin_user) }
10+
11+
describe 'Feature flag web interface' do
12+
it 'is hidden from unauthenticated users' do
13+
# Act
14+
get '/admin/flipper/features'
15+
16+
# Assert
17+
expect(response).to have_http_status(:not_found)
18+
end
19+
20+
it 'is hidden from student users' do
21+
# Arrange
22+
sign_in student
23+
24+
# Act
25+
get '/admin/flipper/features'
26+
27+
# Assert
28+
expect(response).to have_http_status(:not_found)
29+
end
30+
31+
it 'is hidden from teacher users' do
32+
# Arrange
33+
sign_in teacher
34+
35+
# Act
36+
get '/admin/flipper/features'
37+
38+
# Assert
39+
expect(response).to have_http_status(:not_found)
40+
end
41+
42+
it 'is visible to admins' do
43+
# Arrange
44+
sign_in admin_user
45+
46+
# Act
47+
get '/admin/flipper/features'
48+
49+
# Assert
50+
expect(response).to have_http_status(:success)
51+
end
52+
end
53+
end

spec/requests/api/features_spec.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Features' do
6+
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
7+
let(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) }
8+
let(:school) { create(:school) }
9+
let(:student) { create(:student, school:) }
10+
let(:teacher) { create(:teacher, school:) }
11+
let(:admin_user) { create(:admin_user) }
12+
13+
describe 'Feature flag API' do
14+
it 'returns a globally-disabled feature as disabled' do
15+
# Arrange
16+
Flipper.disable :some_global_feature
17+
18+
# Act
19+
get '/api/features'
20+
21+
# Assert
22+
expect(response.body).not_to include('some_global_feature')
23+
end
24+
25+
it 'returns a globally-enabled feature as enabled' do
26+
# Arrange
27+
Flipper.enable :some_global_feature
28+
29+
# Act
30+
get '/api/features'
31+
32+
# Assert
33+
expect(response.body).to include('some_global_feature')
34+
end
35+
36+
it 'returns a school-level feature as disabled for logged-out user' do
37+
# Arrange
38+
Flipper.enable_actor :some_school_level_feature, school
39+
40+
# Act
41+
get '/api/features'
42+
43+
# Assert
44+
expect(response.body).not_to include('some_school_level_feature')
45+
end
46+
47+
it 'returns a school-level feature as enabled for a student in that school' do
48+
# Arrange
49+
authenticated_in_hydra_as(student)
50+
51+
Flipper.enable_actor :some_school_level_feature, school
52+
53+
# Act
54+
get '/api/features', headers: headers
55+
56+
# Assert
57+
expect(response.body).to include('some_school_level_feature')
58+
end
59+
60+
it 'returns both school-level and global features as enabled for a student in a school' do
61+
# Arrange
62+
authenticated_in_hydra_as(student)
63+
64+
Flipper.enable_actor :some_school_level_feature, school
65+
Flipper.enable :some_global_feature
66+
67+
# Act
68+
get '/api/features', headers: headers
69+
70+
# Assert
71+
expect(response.body).to include('some_school_level_feature')
72+
expect(response.body).to include('some_global_feature')
73+
end
74+
end
75+
end

0 commit comments

Comments
 (0)