From b83d5b0b04dd69142ef860f4a3732eb45d319339 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:54:30 -0400 Subject: [PATCH] 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 --- .../storage_settings_controller.rb | 5 ++ app/views/shared/_settings_nav.html.erb | 2 +- app/views/storage_settings/index.html.erb | 20 +++++- config/locales/i18n.yml | 1 + lib/external_config.rb | 65 ++++++++++++++++- spec/lib/external_config_spec.rb | 71 +++++++++++++++++++ 6 files changed, 160 insertions(+), 4 deletions(-) diff --git a/app/controllers/storage_settings_controller.rb b/app/controllers/storage_settings_controller.rb index 4b3a8390..f1385bd1 100644 --- a/app/controllers/storage_settings_controller.rb +++ b/app/controllers/storage_settings_controller.rb @@ -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 diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index af0303a8..7ca46214 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -18,7 +18,7 @@ <%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %> <% 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 %>
  • <%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
  • diff --git a/app/views/storage_settings/index.html.erb b/app/views/storage_settings/index.html.erb index 9a53e79a..c6f94b49 100644 --- a/app/views/storage_settings/index.html.erb +++ b/app/views/storage_settings/index.html.erb @@ -4,9 +4,24 @@

    <%= t('storage') %>

    - <% value = @encrypted_config.value || { 'service' => 'disk' } %> - <% configs = value['configs'] || {} %> + <% external_storage = ExternalConfig.storage_configured? %> + <% if external_storage %> +
    + <%= t('storage_is_configured_via_environment_variables') %> +
    + <% 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 %> +
    + <% else %> +
    + <% end %> <% options = [%w[Disk disk], %w[AWS aws_s3], %w[GCP google], %w[Azure azure]] %>
      @@ -33,6 +48,7 @@
      <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
      +
    <% end %>
    diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 5adbb25d..7d5f8ea7 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -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)" diff --git a/lib/external_config.rb b/lib/external_config.rb index 903afbac..7a8d3160 100644 --- a/lib/external_config.rb +++ b/lib/external_config.rb @@ -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 diff --git a/spec/lib/external_config_spec.rb b/spec/lib/external_config_spec.rb index d20ee38d..8dba6886 100644 --- a/spec/lib/external_config_spec.rb +++ b/spec/lib/external_config_spec.rb @@ -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