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 %>
+
+
+ Vault is not currently setup for your application. This means your secrets are being stored as ENV variables and cannot be updated here. To update them you will need to update the appropriate files in your environment.
+
+
+<% end %>
+
+
+
+
+ | Secret Name |
+ Secret Value |
+ Action |
+
+
+
+ <% @secrets.each do |key, partial_value| %>
+
+ <%= form_with(url: "/internal/secrets", method: "PUT", local: true) do %>
+ | <%= 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) %> |
+ <% end %>
+
+ <% end %>
+
+
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