feat: externalize storage config via environment variables (v1.5.0) (#14)

* feat: externalize storage config via environment variables (v1.5.0)

- Add storage_configured?, storage_service, storage_settings to ExternalConfig
- Show read-only banner and disable form when S3/GCS/Azure env vars are set
- Keep storage link visible in settings nav when externally configured
- Add 8 RSpec tests for storage config detection and settings

* fix: add server-side guard to reject storage form POST when env-configured

Addresses Devin Review feedback: the disabled fieldset was client-side only.
Now StorageSettingsController#create redirects with an alert when
ExternalConfig.storage_configured? is true.

* fix: use i18n key for storage read-only banner instead of hardcoded string

* fix: use conditional fieldset tags to avoid BetterHtml interpolation error

---------

Co-authored-by: Bob Develop <developbob50@gmail.com>
pull/639/head
devin-ai-integration[bot] 1 week ago committed by GitHub
parent 552a42b61e
commit b83d5b0b04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -8,6 +8,11 @@ class StorageSettingsController < ApplicationController
def index; end
def create
if ExternalConfig.storage_configured?
return redirect_to settings_storage_index_path,
alert: I18n.t('storage_is_configured_via_environment_variables')
end
if @encrypted_config.update(storage_configs)
LoadActiveStorageConfigs.reload

@ -18,7 +18,7 @@
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user && ENV['S3_ATTACHMENTS_BUCKET'].blank? && ENV['GCS_BUCKET'].blank? && ENV['AZURE_CONTAINER'].blank? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user %>
<li>
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
</li>

@ -4,9 +4,24 @@
<h1 class="text-4xl font-bold mb-4">
<%= t('storage') %>
</h1>
<% value = @encrypted_config.value || { 'service' => 'disk' } %>
<% configs = value['configs'] || {} %>
<% external_storage = ExternalConfig.storage_configured? %>
<% if external_storage %>
<div class="alert alert-info mb-4" role="status">
<%= t('storage_is_configured_via_environment_variables') %>
</div>
<% ext = ExternalConfig.storage_settings %>
<% value = ext %>
<% configs = ext['configs'] || {} %>
<% else %>
<% value = @encrypted_config.value || { 'service' => 'disk' } %>
<% configs = value['configs'] || {} %>
<% end %>
<%= form_for @encrypted_config, url: settings_storage_index_path, method: :post, html: { autocomplete: 'off', class: 'w-full' } do |f| %>
<% if external_storage %>
<fieldset disabled>
<% else %>
<fieldset>
<% end %>
<% options = [%w[Disk disk], %w[AWS aws_s3], %w[GCP google], %w[Azure azure]] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="block relative">
<ul class="items-center w-full text-sm font-medium text-gray-900 space-y-2 sm:space-y-0 sm:flex sm:space-x-2">
@ -33,6 +48,7 @@
<div class="form-control">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
</fieldset>
<% end %>
</div>
<div class="w-0 md:w-52"></div>

@ -4,6 +4,7 @@ en: &en
show_api_link: Show API Link
show_test_mode: Show Test Mode
locked_by_env: Locked by environment variable
storage_is_configured_via_environment_variables: Storage is configured via environment variables. These settings are read-only.
visibility: Visibility
visibility_private: "Private (only you)"
visibility_public: "Public (all account users)"

@ -1,7 +1,7 @@
# frozen_string_literal: true
# External configuration loaded from environment variables.
# Currently exposes SMTP settings; additional external config concerns can be
# Exposes SMTP and storage settings; additional external config concerns can be
# added here as needed.
module ExternalConfig
CONFIG_DIR = ENV.fetch('DOCUSEAL_CONFIG_DIR', '/etc/docuseal')
@ -15,6 +15,14 @@ module ExternalConfig
from: 'DOCUSEAL_CONFIG_SMTP_FROM'
}.freeze
STORAGE_ENV_KEYS = {
bucket: 'S3_ATTACHMENTS_BUCKET',
access_key_id: 'AWS_ACCESS_KEY_ID',
secret_access_key: 'AWS_SECRET_ACCESS_KEY',
region: 'AWS_REGION',
endpoint: 'S3_ENDPOINT'
}.freeze
module_function
# SMTP is considered configured as soon as an address is provided via ENV.
@ -41,4 +49,59 @@ module ExternalConfig
read_timeout: ENV.fetch('SMTP_READ_TIMEOUT', '25').to_i
}.compact_blank
end
# Storage is considered configured when S3_ATTACHMENTS_BUCKET, GCS_BUCKET, or
# AZURE_CONTAINER is provided via ENV.
def storage_configured?
ENV.fetch('S3_ATTACHMENTS_BUCKET', nil).present? ||
ENV.fetch('GCS_BUCKET', nil).present? ||
ENV.fetch('AZURE_CONTAINER', nil).present?
end
# Returns the externally-configured storage service name.
def storage_service
if ENV.fetch('S3_ATTACHMENTS_BUCKET', nil).present?
'aws_s3'
elsif ENV.fetch('GCS_BUCKET', nil).present?
'google'
elsif ENV.fetch('AZURE_CONTAINER', nil).present?
'azure'
end
end
# Returns a display-friendly hash of the current storage configuration
# sourced from ENV vars.
def storage_settings
return {} unless storage_configured?
service = storage_service
configs = storage_configs_for(service)
return {} if configs.nil?
{ 'service' => service, 'configs' => configs.compact_blank }
end
def storage_configs_for(service)
case service
when 'aws_s3'
{
'access_key_id' => ENV.fetch(STORAGE_ENV_KEYS[:access_key_id], nil),
'secret_access_key' => ENV.fetch(STORAGE_ENV_KEYS[:secret_access_key], nil),
'region' => ENV.fetch(STORAGE_ENV_KEYS[:region], 'us-east-1'),
'bucket' => ENV.fetch(STORAGE_ENV_KEYS[:bucket], nil),
'endpoint' => ENV.fetch(STORAGE_ENV_KEYS[:endpoint], nil)
}
when 'google'
{
'bucket' => ENV.fetch('GCS_BUCKET', nil),
'project' => ENV.fetch('GCS_PROJECT', nil)
}
when 'azure'
{
'storage_account_name' => ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', nil),
'container' => ENV.fetch('AZURE_CONTAINER', nil)
}
end
end
end

@ -46,4 +46,75 @@ RSpec.describe ExternalConfig do
end
end
end
describe '.storage_configured?' do
it 'returns true when S3_ATTACHMENTS_BUCKET is set' do
with_env('S3_ATTACHMENTS_BUCKET' => 'my-bucket') do
expect(described_class.storage_configured?).to be(true)
end
end
it 'returns true when GCS_BUCKET is set' do
with_env('S3_ATTACHMENTS_BUCKET' => nil, 'GCS_BUCKET' => 'my-gcs-bucket') do
expect(described_class.storage_configured?).to be(true)
end
end
it 'returns true when AZURE_CONTAINER is set' do
with_env('S3_ATTACHMENTS_BUCKET' => nil, 'GCS_BUCKET' => nil, 'AZURE_CONTAINER' => 'my-container') do
expect(described_class.storage_configured?).to be(true)
end
end
it 'returns false when no storage env var is set' do
with_env('S3_ATTACHMENTS_BUCKET' => nil, 'GCS_BUCKET' => nil, 'AZURE_CONTAINER' => nil) do
expect(described_class.storage_configured?).to be(false)
end
end
end
describe '.storage_service' do
it 'returns aws_s3 when S3_ATTACHMENTS_BUCKET is set' do
with_env('S3_ATTACHMENTS_BUCKET' => 'my-bucket') do
expect(described_class.storage_service).to eq('aws_s3')
end
end
it 'returns google when GCS_BUCKET is set' do
with_env('S3_ATTACHMENTS_BUCKET' => nil, 'GCS_BUCKET' => 'my-gcs-bucket') do
expect(described_class.storage_service).to eq('google')
end
end
it 'returns azure when AZURE_CONTAINER is set' do
with_env('S3_ATTACHMENTS_BUCKET' => nil, 'GCS_BUCKET' => nil, 'AZURE_CONTAINER' => 'my-container') do
expect(described_class.storage_service).to eq('azure')
end
end
end
describe '.storage_settings' do
it 'returns empty hash when not configured' do
with_env('S3_ATTACHMENTS_BUCKET' => nil, 'GCS_BUCKET' => nil, 'AZURE_CONTAINER' => nil) do
expect(described_class.storage_settings).to eq({})
end
end
it 'returns AWS S3 config hash from env vars' do
envs = {
'S3_ATTACHMENTS_BUCKET' => 'my-bucket',
'AWS_ACCESS_KEY_ID' => 'AKIAEXAMPLE',
'AWS_SECRET_ACCESS_KEY' => 'secret123',
'AWS_REGION' => 'ca-central-1',
'S3_ENDPOINT' => nil
}
with_env(envs) do
settings = described_class.storage_settings
expect(settings['service']).to eq('aws_s3')
expect(settings['configs']['bucket']).to eq('my-bucket')
expect(settings['configs']['access_key_id']).to eq('AKIAEXAMPLE')
expect(settings['configs']['region']).to eq('ca-central-1')
end
end
end
end

Loading…
Cancel
Save