[deploy] Feature: Setup Admin Area to Update Vault Secrets (#8935)
This commit is contained in:
parent
bacff174a6
commit
4e0fee644c
8 changed files with 165 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
33
app/controllers/internal/secrets_controller.rb
Normal file
33
app/controllers/internal/secrets_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
7
app/models/secret.rb
Normal file
7
app/models/secret.rb
Normal file
|
|
@ -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
|
||||
29
app/views/internal/secrets/index.html.erb
Normal file
29
app/views/internal/secrets/index.html.erb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<% if !@vault_enabled %>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
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.
|
||||
<p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Secret Name</th>
|
||||
<th scope="col">Secret Value</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @secrets.each do |key, partial_value| %>
|
||||
<tr>
|
||||
<%= form_with(url: "/internal/secrets", method: "PUT", local: true) do %>
|
||||
<td><%= label_tag key, key %></td>
|
||||
<td><%= text_field_tag key, partial_value %></td>
|
||||
<td>
|
||||
<%= submit_tag("Update", data: { confirm: "My username is @#{current_user.username} and I want to update this Vault Secret." }, disabled: !@vault_enabled) %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
spec/requests/internal/secrets_spec.rb
Normal file
64
spec/requests/internal/secrets_spec.rb
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue