From dc12885dfbc1b556bdb2d1a08b7f1224b3b776a8 Mon Sep 17 00:00:00 2001 From: Matthew Trew Date: Mon, 26 Jan 2026 15:19:49 +0100 Subject: [PATCH 1/4] Add feature flag support via Flipper Also adds an endpoint that returns feature flag statuses, following the example at https://blog.flippercloud.io/client-side-feature-flags/. --- Gemfile | 2 + Gemfile.lock | 7 +++ app/controllers/api/features_controller.rb | 15 +++++++ app/helpers/features_helper.rb | 2 + config/initializers/flipper.rb | 45 +++++++++++++++++++ config/routes.rb | 2 + .../20260126130135_create_flipper_tables.rb | 22 +++++++++ db/schema.rb | 18 +++++++- spec/requests/api/features_spec.rb | 16 +++++++ 9 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/features_controller.rb create mode 100644 app/helpers/features_helper.rb create mode 100644 config/initializers/flipper.rb create mode 100644 db/migrate/20260126130135_create_flipper_tables.rb create mode 100644 spec/requests/api/features_spec.rb diff --git a/Gemfile b/Gemfile index 48a0ccbfd..3afde6596 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,8 @@ gem 'rails', '~> 7.1' gem 'scout_apm' gem 'sentry-rails' gem 'statesman' +gem "flipper", "~> 1.3" +gem "flipper-active_record", "~> 1.3" group :development, :test do gem 'awesome_print' diff --git a/Gemfile.lock b/Gemfile.lock index 2b8327168..85a249573 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,6 +186,11 @@ GEM ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-storage (1.0.1) + flipper (1.3.6) + concurrent-ruby (< 2) + flipper-active_record (1.3.6) + activerecord (>= 4.2, < 9) + flipper (~> 1.3.6) fugit (1.11.2) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -579,6 +584,8 @@ DEPENDENCIES factory_bot_rails faker faraday + flipper (~> 1.3) + flipper-active_record (~> 1.3) github_webhook (~> 1.4) globalid good_job (~> 4.3) diff --git a/app/controllers/api/features_controller.rb b/app/controllers/api/features_controller.rb new file mode 100644 index 000000000..53ffe5316 --- /dev/null +++ b/app/controllers/api/features_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Api + class FeaturesController < ApiController + def index + Rails.logger.info "Features: #{Flipper.features}" + features = Flipper.features.map do |feature| + # todo: reveal only features that are enabled. + [feature.key, Flipper.enabled?(feature.key, current_user)] + end + + render json: features.to_h + end + end +end diff --git a/app/helpers/features_helper.rb b/app/helpers/features_helper.rb new file mode 100644 index 000000000..1a5e6b20f --- /dev/null +++ b/app/helpers/features_helper.rb @@ -0,0 +1,2 @@ +module FeaturesHelper +end diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb new file mode 100644 index 000000000..52c07d614 --- /dev/null +++ b/config/initializers/flipper.rb @@ -0,0 +1,45 @@ +Rails.application.configure do + ## Memoization ensures that only one adapter call is made per feature per request. + ## For more info, see https://www.flippercloud.io/docs/optimization#memoization + # config.flipper.memoize = true + + ## Flipper preloads all features before each request, which is recommended if: + ## * you have a limited number of features (< 100?) + ## * most of your requests depend on most of your features + ## * you have limited gate data combined across all features (< 1k enabled gates, like individual actors, across all features) + ## + ## For more info, see https://www.flippercloud.io/docs/optimization#preloading + # config.flipper.preload = true + + ## Warn or raise an error if an unknown feature is checked + ## Can be set to `:warn`, `:raise`, or `false` + # config.flipper.strict = Rails.env.development? && :warn + + ## Show Flipper checks in logs + # config.flipper.log = true + + ## Reconfigure Flipper to use the Memory adapter and disable Cloud in tests + # config.flipper.test_help = true + + ## The path that Flipper Cloud will use to sync features + # config.flipper.cloud_path = "_flipper" + + ## The instrumenter that Flipper will use. Defaults to ActiveSupport::Notifications. + # config.flipper.instrumenter = ActiveSupport::Notifications +end + +Flipper.configure do |config| + ## Configure other adapters that you want to use here: + ## See http://flippercloud.io/docs/adapters + # config.use Flipper::Adapters::ActiveSupportCacheStore, Rails.cache, expires_in: 5.minutes +end + +## Register a group that can be used for enabling features. +## +## Flipper.enable_group :my_feature, :admins +## +## See https://www.flippercloud.io/docs/features#enablement-group +# +# Flipper.register(:admins) do |actor| +# actor.respond_to?(:admin?) && actor.admin? +# end diff --git a/config/routes.rb b/config/routes.rb index dc131d2ed..4620c7771 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,6 +87,8 @@ resources :school_import_jobs, only: %i[show] post '/google/auth/exchange-code', to: 'google_auth#exchange_code', defaults: { format: :json } + + resources :features, only: %i[index] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/db/migrate/20260126130135_create_flipper_tables.rb b/db/migrate/20260126130135_create_flipper_tables.rb new file mode 100644 index 000000000..6f076800b --- /dev/null +++ b/db/migrate/20260126130135_create_flipper_tables.rb @@ -0,0 +1,22 @@ +class CreateFlipperTables < ActiveRecord::Migration[7.2] + def up + create_table :flipper_features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :flipper_features, :key, unique: true + + create_table :flipper_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.text :value + t.timestamps null: false + end + add_index :flipper_gates, [:feature_key, :key, :value], unique: true, length: { value: 255 } + end + + def down + drop_table :flipper_gates + drop_table :flipper_features + end +end diff --git a/db/schema.rb b/db/schema.rb index 084e3e354..356389a32 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_21_103308) do +ActiveRecord::Schema[7.2].define(version: 2026_01_26_130135) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -84,6 +84,22 @@ t.index ["school_project_id"], name: "index_feedback_on_school_project_id" end + create_table "flipper_features", force: :cascade do |t| + t.string "key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_flipper_features_on_key", unique: true + end + + create_table "flipper_gates", force: :cascade do |t| + t.string "feature_key", null: false + t.string "key", null: false + t.text "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true + end + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb new file mode 100644 index 000000000..cdf4ceaa5 --- /dev/null +++ b/spec/requests/api/features_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe "Features", type: :request do + it "Returns a globally-enabled feature as enabled" do + # Arrange + Flipper.enable :some_cool_feature + + # Act + get "/api/features" + + # Assert + expect(response.body).to include('"some_cool_feature":true') + end +end From cdc75fb96529d8e0150a254c9ef4f70422ca21ca Mon Sep 17 00:00:00 2001 From: Matthew Trew Date: Mon, 26 Jan 2026 15:36:52 +0100 Subject: [PATCH 2/4] Enable Flipper UI web interface --- Gemfile | 2 ++ Gemfile.lock | 11 +++++++++++ config/routes.rb | 2 ++ spec/requests/api/features_spec.rb | 27 ++++++++++++++++++++------- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index 3afde6596..4208d66a5 100644 --- a/Gemfile +++ b/Gemfile @@ -78,3 +78,5 @@ group :test do gem 'webdrivers' gem 'webmock' end + +gem "flipper-ui", "~> 1.3" diff --git a/Gemfile.lock b/Gemfile.lock index 85a249573..c42fb4572 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,6 +191,13 @@ GEM flipper-active_record (1.3.6) activerecord (>= 4.2, < 9) flipper (~> 1.3.6) + flipper-ui (1.3.6) + erubi (>= 1.0.0, < 2.0.0) + flipper (~> 1.3.6) + rack (>= 1.4, < 4) + rack-protection (>= 1.5.3, < 5.0.0) + rack-session (>= 1.0.2, < 3.0.0) + sanitize (< 8) fugit (1.11.2) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -486,6 +493,9 @@ GEM ffi (~> 1.12) logger rubyzip (3.0.2) + sanitize (7.0.0) + crass (~> 1.0.2) + nokogiri (>= 1.16.8) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -586,6 +596,7 @@ DEPENDENCIES faraday flipper (~> 1.3) flipper-active_record (~> 1.3) + flipper-ui (~> 1.3) github_webhook (~> 1.4) globalid good_job (~> 4.3) diff --git a/config/routes.rb b/config/routes.rb index 4620c7771..15d7e8cbd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,8 @@ mount GoodJob::Engine => 'good_job' resources :components + mount Flipper::UI.app(Flipper) => '/flipper' + resources :projects do delete :images, on: :member, action: :destroy_image end diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index cdf4ceaa5..ea9a853a4 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -3,14 +3,27 @@ require 'rails_helper' RSpec.describe "Features", type: :request do - it "Returns a globally-enabled feature as enabled" do - # Arrange - Flipper.enable :some_cool_feature - # Act - get "/api/features" + describe "Feature flag API" do + it "returns a globally-enabled feature as enabled" do + # Arrange + Flipper.enable :some_cool_feature - # Assert - expect(response.body).to include('"some_cool_feature":true') + # Act + get "/api/features" + + # Assert + expect(response.body).to include('"some_cool_feature":true') + end + end + + describe "Feature flag web interface" do + it "is visible at /admin/flipper/features" do + # Act + get "/admin/flipper/features" + + # Assert + expect(response).to have_http_status(:success) + end end end From 09bf0acb71b79466ca66ff610abbafbcb43e5ce5 Mon Sep 17 00:00:00 2001 From: Matthew Trew Date: Tue, 27 Jan 2026 16:59:57 +0100 Subject: [PATCH 3/4] Enable school-level flags for logged-in users --- app/controllers/api/features_controller.rb | 4 +- spec/requests/api/features_spec.rb | 65 +++++++++++++++++++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/features_controller.rb b/app/controllers/api/features_controller.rb index 53ffe5316..8d227e6a4 100644 --- a/app/controllers/api/features_controller.rb +++ b/app/controllers/api/features_controller.rb @@ -3,10 +3,8 @@ module Api class FeaturesController < ApiController def index - Rails.logger.info "Features: #{Flipper.features}" features = Flipper.features.map do |feature| - # todo: reveal only features that are enabled. - [feature.key, Flipper.enabled?(feature.key, current_user)] + [feature.key, Flipper.enabled?(feature.key, current_user&.schools&.first)] end render json: features.to_h diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index ea9a853a4..44510989d 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -3,18 +3,76 @@ require 'rails_helper' RSpec.describe "Features", type: :request do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) } + let(:school) { create(:school) } + let(:student) { create(:student, school:) } + let(:teacher) { create(:teacher, school:) } describe "Feature flag API" do + it "returns a globally-disabled feature as disabled" do + # Arrange + Flipper.disable :some_global_feature + + # Act + get "/api/features" + + # Assert + expect(response.body).not_to include('"some_global_feature":true') + end + it "returns a globally-enabled feature as enabled" do # Arrange - Flipper.enable :some_cool_feature + Flipper.enable :some_global_feature + + # Act + get "/api/features" + + # Assert + expect(response.body).to include('"some_global_feature":true') + end + + it "returns a school-level feature as disabled for logged-out user" do + # Arrange + Flipper.enable_actor :some_school_level_feature, school # Act get "/api/features" # Assert - expect(response.body).to include('"some_cool_feature":true') + expect(response.body).not_to include('"some_school_level_feature":true') + end + + it "returns a school-level feature as enabled for a student in that school" do + # Arrange + authenticated_in_hydra_as(student) + + Flipper.enable_actor :some_school_level_feature, school + + # Act + get "/api/features", headers: headers + + # Assert + expect(response.body).to include('"some_school_level_feature":true') + end + + it "returns both school-level and global features as enabled for a student in a school" do + # Arrange + authenticated_in_hydra_as(student) + + Flipper.enable_actor :some_school_level_feature, school + Flipper.enable :some_global_feature + + # Act + get "/api/features", headers: headers + + # Assert + expect(response.body).to include('"some_school_level_feature":true') + expect(response.body).to include('"some_global_feature":true') end + + # todo: Don't leak the existence of disabled feature flags + end describe "Feature flag web interface" do @@ -25,5 +83,8 @@ # Assert expect(response).to have_http_status(:success) end + + # todo: Only admins should be able to access UI + end end From 8ea7e689a462b126418fad744ddcb5884b39408f Mon Sep 17 00:00:00 2001 From: Matthew Trew Date: Thu, 29 Jan 2026 16:04:08 +0100 Subject: [PATCH 4/4] Require admin authentication for Flipper UI access --- config/routes.rb | 3 +- lib/helpers/admin_session_constraint.rb | 10 +++++++ spec/requests/api/features_spec.rb | 37 +++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 lib/helpers/admin_session_constraint.rb diff --git a/config/routes.rb b/config/routes.rb index 15d7e8cbd..500644cb6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,7 +5,8 @@ mount GoodJob::Engine => 'good_job' resources :components - mount Flipper::UI.app(Flipper) => '/flipper' + mount Flipper::UI.app(Flipper) => '/flipper', + constraints: AdminSessionConstraint.new resources :projects do delete :images, on: :member, action: :destroy_image diff --git a/lib/helpers/admin_session_constraint.rb b/lib/helpers/admin_session_constraint.rb new file mode 100644 index 000000000..12bebe5ed --- /dev/null +++ b/lib/helpers/admin_session_constraint.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AdminSessionConstraint + def matches?(request) + current_user = request.session[:current_user] + return false unless current_user + + User.new(current_user).admin? + end +end diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index 44510989d..0f98d67c2 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -8,6 +8,7 @@ let(:school) { create(:school) } let(:student) { create(:student, school:) } let(:teacher) { create(:teacher, school:) } + let(:admin_user) { create(:admin_user) } describe "Feature flag API" do it "returns a globally-disabled feature as disabled" do @@ -76,15 +77,45 @@ end describe "Feature flag web interface" do - it "is visible at /admin/flipper/features" do + it "is hidden from unauthenticated users" do # Act get "/admin/flipper/features" # Assert - expect(response).to have_http_status(:success) + expect(response).to have_http_status(:not_found) + end + + it "is hidden from student users" do + # Arrange + sign_in student + + # Act + get "/admin/flipper/features" + + # Assert + expect(response).to have_http_status(:not_found) + end + + it "is hidden from teacher users" do + # Arrange + sign_in teacher + + # Act + get "/admin/flipper/features" + + # Assert + expect(response).to have_http_status(:not_found) end - # todo: Only admins should be able to access UI + it "is visible to admins" do + # Arrange + sign_in admin_user + + # Act + get "/admin/flipper/features" + # Assert + expect(response).to have_http_status(:success) + end end end