diff --git a/Gemfile b/Gemfile index 48a0ccbfd..4208d66a5 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' @@ -76,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 2b8327168..c42fb4572 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,6 +186,18 @@ 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) + 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) @@ -481,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) @@ -579,6 +594,9 @@ DEPENDENCIES factory_bot_rails faker 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/app/controllers/api/features_controller.rb b/app/controllers/api/features_controller.rb new file mode 100644 index 000000000..8d227e6a4 --- /dev/null +++ b/app/controllers/api/features_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Api + class FeaturesController < ApiController + def index + features = Flipper.features.map do |feature| + [feature.key, Flipper.enabled?(feature.key, current_user&.schools&.first)] + 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..500644cb6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,9 @@ mount GoodJob::Engine => 'good_job' resources :components + mount Flipper::UI.app(Flipper) => '/flipper', + constraints: AdminSessionConstraint.new + resources :projects do delete :images, on: :member, action: :destroy_image end @@ -87,6 +90,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/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 new file mode 100644 index 000000000..0f98d67c2 --- /dev/null +++ b/spec/requests/api/features_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +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:) } + let(:admin_user) { create(:admin_user) } + + 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_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).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 + it "is hidden from unauthenticated users" do + # Act + get "/admin/flipper/features" + + # Assert + 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 + + 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