feat(config): env override pattern (DOCUSEAL_CONFIG_*)

pull/639/head
Bob Develop 2 weeks ago
parent 32e244f522
commit 05e312bd14

@ -56,6 +56,46 @@ class Account < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
after_create_commit :apply_env_config_overrides
# Collect all DOCUSEAL_CONFIG_* env vars as a { key => casted_value } hash.
def self.env_config_overrides
prefix = AccountConfig::ENV_PREFIX
ENV.each_with_object({}) do |(var, _), acc|
next unless var.start_with?(prefix)
key = var.sub(prefix, '').downcase
acc[key] = AccountConfig.env_override_cast(key)
end
end
# Upsert env overrides into this account's account_configs rows.
def apply_env_config_overrides
self.class.env_config_overrides.each do |key, value|
cfg = account_configs.find_or_initialize_by(key: key)
cfg.value = value
cfg.save!
end
end
# Returns [value, locked_by_env?] for a config key. Env value takes precedence.
def config_value(key, default: nil)
if AccountConfig.locked_by_env?(key)
[AccountConfig.env_override_cast(key), true]
else
row = account_configs.find_by(key: key)
[row ? row.value : default, false]
end
end
# Convenience: treats the config value as a boolean visibility flag.
def ui_visible?(key, default: true)
value, _locked = config_value(key, default: default)
return default if value.nil?
ActiveModel::Type::Boolean.new.cast(value)
end
def testing?
linked_account_account&.testing?
end

@ -94,7 +94,48 @@ class AccountConfig < ApplicationRecord
}
}.freeze
ENV_PREFIX = 'DOCUSEAL_CONFIG_'
BOOLEAN_ENV_VALUES = {
'true' => true, '1' => true, 'yes' => true, 'on' => true,
'false' => false, '0' => false, 'no' => false, 'off' => false
}.freeze
belongs_to :account
serialize :value, coder: JSON
# Returns the ENV variable name for a given account_config key.
# Example: env_key_for('allow_typed_signature') => 'DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE'
def self.env_key_for(key)
"#{ENV_PREFIX}#{key.to_s.upcase}"
end
# Returns the raw ENV value for a key (or nil if not set).
def self.env_override(key)
ENV[env_key_for(key)]
end
# True when the corresponding ENV variable is set (non-nil, non-empty).
def self.locked_by_env?(key)
ENV.fetch(env_key_for(key), nil).present?
end
# Parses the ENV override for a given key:
# - boolean-ish strings -> true/false
# - valid JSON -> parsed structure
# - otherwise -> raw string
def self.env_override_cast(key)
raw = env_override(key)
return nil if raw.nil?
downcased = raw.downcase
return BOOLEAN_ENV_VALUES[downcased] if BOOLEAN_ENV_VALUES.key?(downcased)
begin
JSON.parse(raw)
rescue JSON::ParserError
raw
end
end
end

@ -0,0 +1,15 @@
# frozen_string_literal: true
# Seeds DOCUSEAL_CONFIG_* env overrides into every existing account on boot.
# New accounts receive overrides via Account#after_create_commit.
#
# Runs only when at least one override env var is set and the accounts table is ready.
Rails.application.config.after_initialize do
next if Rails.env.test?
next unless ActiveRecord::Base.connection.data_source_exists?('accounts')
next if Account.env_config_overrides.empty?
Account.find_each(&:apply_env_config_overrides)
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid => e
Rails.logger.warn("[account_config_env_overrides] skipped: #{e.class}: #{e.message}")
end

@ -0,0 +1,58 @@
# Environment Variable Config Overrides
Any `account_config` value can be locked via an environment variable using the
`DOCUSEAL_CONFIG_<UPCASE_KEY>` pattern. When set, the value takes precedence
over the database and the corresponding UI toggle is rendered as disabled with
a tooltip "Locked by environment variable".
## Value parsing rules
| Raw ENV value | Parsed as |
|------------------------------------|---------------------------|
| `true`, `1`, `yes`, `on` | boolean `true` |
| `false`, `0`, `no`, `off` | boolean `false` |
| Valid JSON (object / array / num) | parsed JSON |
| Anything else | raw string |
Comparison is case-insensitive for booleans.
## Examples
```bash
# Boolean toggle
DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE=true
# JSON object
DOCUSEAL_CONFIG_POLICY_LINKS='{"privacy":"https://example.com/privacy"}'
# Plain string
DOCUSEAL_CONFIG_DOCUMENT_FILENAME_FORMAT="{{template.name}}-{{submitter.name}}"
```
## Supported keys (non-exhaustive)
All keys declared as constants in `app/models/account_config.rb` are supported.
A few common ones:
| ENV variable | Account config key |
|--------------------------------------------------------|----------------------------------|
| `DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE` | `allow_typed_signature` |
| `DOCUSEAL_CONFIG_ALLOW_TO_DECLINE` | `allow_to_decline` |
| `DOCUSEAL_CONFIG_ENFORCE_SIGNING_ORDER` | `enforce_signing_order` |
| `DOCUSEAL_CONFIG_FORCE_MFA` | `force_mfa` |
| `DOCUSEAL_CONFIG_EMAIL_FOOTER_MESSAGE` | `email_footer_message` |
| `DOCUSEAL_CONFIG_SHOW_CONSOLE_LINK` | `show_console_link` |
| `DOCUSEAL_CONFIG_SHOW_API_LINK` | `show_api_link` |
| `DOCUSEAL_CONFIG_SHOW_TEST_MODE` | `show_test_mode` |
## How it works
- `AccountConfig.locked_by_env?(key)` returns `true` when the matching env var
is set (non-blank).
- `AccountConfig.env_override_cast(key)` parses the value per the rules above.
- On `Account` create (`after_create_commit`), all env overrides are upserted
into `account_configs`.
- On boot (`config/initializers/account_config_env_overrides.rb`), overrides
are applied to all existing accounts so the DB stays in sync with the env.
- Views should check `AccountConfig.locked_by_env?(key)` to render form fields
as disabled with an appropriate tooltip when an override is active.

@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAsAdmin } from './helpers/auth';
// Phase 0.1 — Config override pattern (UI reflection only).
// The env-var swap itself cannot be exercised via Playwright (needs pod restart),
// so we verify the UI renders toggles based on the DB-seeded AccountConfig values.
test.describe('Config overrides — UI reflection', () => {
test('E-Signing settings page loads and renders toggles', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/settings/esign');
// A known account-config backed toggle should be present.
await expect(page.locator('form')).toBeVisible();
});
});

@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AccountConfig, type: :model do
describe '.env_key_for' do
it 'builds the DOCUSEAL_CONFIG_<UPCASE_KEY> env variable name' do
expect(described_class.env_key_for('allow_typed_signature'))
.to eq('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE')
end
end
describe '.locked_by_env?' do
it 'returns true when the matching env var is set' do
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => 'true') do
expect(described_class.locked_by_env?('allow_typed_signature')).to be(true)
end
end
it 'returns false when the matching env var is absent' do
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => nil) do
expect(described_class.locked_by_env?('allow_typed_signature')).to be(false)
end
end
it 'returns false when the matching env var is blank' do
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => '') do
expect(described_class.locked_by_env?('allow_typed_signature')).to be(false)
end
end
end
describe '.env_override_cast' do
it 'casts boolean-ish strings to booleans' do
with_env('DOCUSEAL_CONFIG_FORCE_MFA' => 'true') do
expect(described_class.env_override_cast('force_mfa')).to be(true)
end
with_env('DOCUSEAL_CONFIG_FORCE_MFA' => 'false') do
expect(described_class.env_override_cast('force_mfa')).to be(false)
end
with_env('DOCUSEAL_CONFIG_FORCE_MFA' => '1') do
expect(described_class.env_override_cast('force_mfa')).to be(true)
end
with_env('DOCUSEAL_CONFIG_FORCE_MFA' => '0') do
expect(described_class.env_override_cast('force_mfa')).to be(false)
end
end
it 'parses valid JSON' do
with_env('DOCUSEAL_CONFIG_POLICY_LINKS' => '{"a":1}') do
expect(described_class.env_override_cast('policy_links')).to eq({ 'a' => 1 })
end
end
it 'returns the raw string when not boolean or JSON' do
with_env('DOCUSEAL_CONFIG_DOCUMENT_FILENAME_FORMAT' => 'hello') do
expect(described_class.env_override_cast('document_filename_format')).to eq('hello')
end
end
it 'returns nil when the env var is absent' do
with_env('DOCUSEAL_CONFIG_FORCE_MFA' => nil) do
expect(described_class.env_override_cast('force_mfa')).to be_nil
end
end
end
end

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account, type: :model do
let(:account) { create(:account) }
describe '#apply_env_config_overrides' do
it 'upserts env override values into account_configs' do
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => 'true') do
account.apply_env_config_overrides
row = account.account_configs.find_by(key: 'allow_typed_signature')
expect(row).not_to be_nil
expect(row.value).to be(true)
end
end
it 'overwrites existing DB value with env value' do
account.account_configs.create!(key: 'allow_typed_signature', value: false)
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => 'true') do
account.apply_env_config_overrides
expect(account.account_configs.find_by(key: 'allow_typed_signature').value).to be(true)
end
end
end
describe '#config_value' do
it 'returns env value with locked=true when env set' do
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => 'true') do
value, locked = account.config_value('allow_typed_signature')
expect(value).to be(true)
expect(locked).to be(true)
end
end
it 'returns DB value with locked=false when env absent' do
account.account_configs.create!(key: 'allow_typed_signature', value: true)
with_env('DOCUSEAL_CONFIG_ALLOW_TYPED_SIGNATURE' => nil) do
value, locked = account.config_value('allow_typed_signature')
expect(value).to be(true)
expect(locked).to be(false)
end
end
it 'returns the default when no env var and no DB row' do
value, locked = account.config_value('nonexistent_key', default: :foo)
expect(value).to eq(:foo)
expect(locked).to be(false)
end
end
describe '#ui_visible?' do
it 'returns false when DB value is false' do
account.account_configs.create!(key: 'show_console_link', value: false)
expect(account.ui_visible?('show_console_link')).to be(false)
end
it 'returns true when DB value is true' do
account.account_configs.create!(key: 'show_console_link', value: true)
expect(account.ui_visible?('show_console_link')).to be(true)
end
it 'returns the default when no row and no env var' do
expect(account.ui_visible?('show_console_link', default: true)).to be(true)
expect(account.ui_visible?('show_console_link', default: false)).to be(false)
end
end
end

@ -0,0 +1,23 @@
# frozen_string_literal: true
# Tiny helper to set ENV vars for the duration of a block, then restore.
module EnvHelpers
def with_env(vars)
original = {}
vars.each do |k, v|
original[k] = ENV.fetch(k, nil)
if v.nil?
ENV.delete(k)
else
ENV[k] = v
end
end
yield
ensure
original.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
end
RSpec.configure do |config|
config.include EnvHelpers
end
Loading…
Cancel
Save