diff --git a/app/controllers/internal/application_controller.rb b/app/controllers/internal/application_controller.rb index f03ec7a29..3dc7e944d 100644 --- a/app/controllers/internal/application_controller.rb +++ b/app/controllers/internal/application_controller.rb @@ -28,6 +28,7 @@ class Internal::ApplicationController < ApplicationController { name: "tags", controller: "tags" }, { name: "tools", controller: "tools" }, { name: "users", controller: "users" }, + { name: "vault secrets", controller: "secrets" }, { name: "webhooks", controller: "webhook_endpoints" }, { name: "welcome", controller: "welcome" }, ].sort_by { |menu_item| menu_item[:name] }.freeze diff --git a/app/controllers/internal/secrets_controller.rb b/app/controllers/internal/secrets_controller.rb new file mode 100644 index 000000000..7d7545aa2 --- /dev/null +++ b/app/controllers/internal/secrets_controller.rb @@ -0,0 +1,33 @@ +class Internal::SecretsController < Internal::ApplicationController + layout "internal" + + before_action :validate_settable_secret, only: [:update] + after_action only: [:update] do + Audit::Logger.log(:internal, current_user, params.dup) + end + + def index + @vault_enabled = AppSecrets.vault_enabled? + @secrets = AppSecrets::SETTABLE_SECRETS.map do |key| + secret_value = AppSecrets[key] + secret_value = secret_value.present? ? "#{secret_value.first(8)}******" : "Not In Vault" + [key, secret_value] + end + end + + def update + AppSecrets[params[:key_name]] = params[:key_value] + + flash[:success] = "Secret #{params[:key_name]} was successfully updated in Vault." + redirect_to internal_secrets_path + end + + private + + def validate_settable_secret + update_param = params.permit(*AppSecrets::SETTABLE_SECRETS) + params[:key_name], params[:key_value] = update_param.to_h.to_a.first + + bad_request unless update_param.present? && params[:key_name].is_a?(String) && params[:key_value].is_a?(String) + end +end diff --git a/app/lib/app_secrets.rb b/app/lib/app_secrets.rb index c1b2277f6..c86ec7bdd 100644 --- a/app/lib/app_secrets.rb +++ b/app/lib/app_secrets.rb @@ -1,6 +1,12 @@ class AppSecrets + SETTABLE_SECRETS = %w[ + SLACK_CHANNEL + SLACK_DEPLOY_CHANNEL + SLACK_WEBHOOK_URL + ].freeze + def self.[](key) - result = Vault.kv(namespace).read(key)&.data&.fetch(:value) if ENV["VAULT_TOKEN"].present? + result = Vault.kv(namespace).read(key)&.data&.fetch(:value) if vault_enabled? result ||= ApplicationConfig[key] result @@ -12,6 +18,10 @@ class AppSecrets Vault.kv(namespace).write(key, value: value) end + def self.vault_enabled? + ENV["VAULT_TOKEN"].present? + end + def self.namespace ENV["VAULT_SECRET_NAMESPACE"] end diff --git a/app/models/secret.rb b/app/models/secret.rb new file mode 100644 index 000000000..1ab8ada75 --- /dev/null +++ b/app/models/secret.rb @@ -0,0 +1,7 @@ +class Secret < ApplicationRecord + # This class exists to take advantage of Rolify for limiting authorization + # on internal vault secrets. + # NOTE: It is not backed by a database table and should not be expected to + # function like a traditional Rails model + resourcify +end diff --git a/app/views/internal/secrets/index.html.erb b/app/views/internal/secrets/index.html.erb new file mode 100644 index 000000000..29cec0e8a --- /dev/null +++ b/app/views/internal/secrets/index.html.erb @@ -0,0 +1,29 @@ +<% if !@vault_enabled %> + +<% end %> + + + + + + + + + + + <% @secrets.each do |key, partial_value| %> + + <%= form_with(url: "/internal/secrets", method: "PUT", local: true) do %> + + + + <% end %> + + <% end %> + +
Secret NameSecret ValueAction
<%= label_tag key, key %><%= text_field_tag key, partial_value %> + <%= submit_tag("Update", data: { confirm: "My username is @#{current_user.username} and I want to update this Vault Secret." }, disabled: !@vault_enabled) %>
diff --git a/config/routes.rb b/config/routes.rb index 0b9e1369f..ce10ef568 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -106,6 +106,8 @@ Rails.application.routes.draw do resources :badges, only: :index post "badges/award_badges", to: "badges#award_badges" resources :path_redirects, only: %i[new create index edit update destroy] + resources :secrets, only: %i[index] + put "secrets", to: "secrets#update" end namespace :stories, defaults: { format: "json" } do diff --git a/spec/lib/app_secrets_spec.rb b/spec/lib/app_secrets_spec.rb index 14f18273a..c2c466afb 100644 --- a/spec/lib/app_secrets_spec.rb +++ b/spec/lib/app_secrets_spec.rb @@ -10,13 +10,12 @@ RSpec.describe AppSecrets, type: :lib do allow(described_class).to receive(:namespace).and_return(namespace) allow(Vault).to receive(:kv) { vault_stub } allow(ApplicationConfig).to receive(:[]) - allow(ENV).to receive(:[]) end describe "[]" do - context "with VAULT_TOKEN present" do + context "with vault_enabled" do before do - allow(ENV).to receive(:[]).with("VAULT_TOKEN").and_return("present") + allow(described_class).to receive(:vault_enabled?).and_return(true) end it "fetches keys from Vault" do @@ -40,8 +39,8 @@ RSpec.describe AppSecrets, type: :lib do end end - context "without VAULT_TOKEN present" do - before { allow(ApplicationConfig).to receive(:[]).with("VAULT_TOKEN").and_return("") } + context "without vault_enabled" do + before { allow(described_class).to receive(:vault_enabled?).and_return(false) } it "fetches keys from ApplicationConfig" do allow(Vault).to receive(:kv) { instance_double("Vault::Kv", read: nil) } @@ -62,4 +61,18 @@ RSpec.describe AppSecrets, type: :lib do expect(write_stub).to have_received(:write).with(key, value: "secret-value") end end + + describe "#vault_enabled?" do + before { allow(ENV).to receive(:[]) } + + it "returns true if VAULT_TOKEN present" do + allow(ENV).to receive(:[]).with("VAULT_TOKEN").and_return("present") + expect(described_class.vault_enabled?).to be(true) + end + + it "returns false if VAULT_TOKEN is missing" do + allow(ENV).to receive(:[]).with("VAULT_TOKEN").and_return("") + expect(described_class.vault_enabled?).to be(false) + end + end end diff --git a/spec/requests/internal/secrets_spec.rb b/spec/requests/internal/secrets_spec.rb new file mode 100644 index 000000000..21e499bcc --- /dev/null +++ b/spec/requests/internal/secrets_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe "/internal/secrets", type: :request do + before do + allow(AppSecrets).to receive(:vault_enabled?).and_return(true) + allow(AppSecrets).to receive(:[]=) + end + + context "when the user is not an admin" do + it "blocks the request" do + user = create(:user) + sign_in user + + expect do + get internal_secrets_path + end.to raise_error(Pundit::NotAuthorizedError) + end + end + + context "when the user is an admin" do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + describe "GET /internal/secrets" do + it "renders with status 200" do + get internal_secrets_path + expect(response.status).to eq 200 + end + + it "displays an alert when Vault is not enabled" do + allow(AppSecrets).to receive(:vault_enabled?).and_return(false) + get internal_secrets_path + expect(response.body).to include("Vault is not currently setup for your application") + end + end + + describe "PUT /internal/secrets" do + let(:valid_secret) { AppSecrets::SETTABLE_SECRETS.first } + let(:valid_params) { { valid_secret => "SECRET_VALUE" } } + + it "successfully sets a secret and shows flash message" do + allow(AppSecrets).to receive(:[]=) + put internal_secrets_path, params: valid_params + expect(response.status).to eq 302 + expect(AppSecrets).to have_received(:[]=).with(valid_secret, "SECRET_VALUE") + expect(flash[:success]).to include("Secret #{valid_secret} was successfully updated in Vault") + end + + it "returns a bad_request with invalid params" do + put internal_secrets_path, params: {} + expect(response.status).to eq 400 + end + + it "creates an audit log" do + Audit::Subscribe.listen :internal + expect do + put internal_secrets_path, params: valid_params + end.to change(AuditLog, :count).by(1) + Audit::Subscribe.forget :internal + end + end + end +end