!NOTE
This is a Request for Comments. The design described here is a starting point, not a
final specification. We are actively looking for feedback on the proposed API, the store
contract, the per-setting ergonomics, and anything else that could make this feature more
natural for Hanami users. If you have ideas for a better API shape, use cases we haven’t
considered, or concerns about the approach - please speak up. Nothing here is set in stone.
The Problem
Hanami Settings currently reads all values from a single source: environment variables. While ENV works well for simple deployments, it forces teams to choose between two bad options when it comes to secrets:
-
Put secrets in ENV anyway. This means secrets end up in
.envfiles checked into repos, pasted into CI dashboards, scattered across deploy configs, and visible to anyone who can runprintenvon a server. ENV was designed for configuration, not secrets management. -
Write custom glue code. Teams that already use a password manager (1Password, Bitwarden, LastPass) or a secrets platform (AWS Secrets Manager, HashiCorp Vault) have to build their own plumbing to get those values into Hanami settings - typically by shelling out to a CLI at boot time and stuffing results back into ENV before the app loads. This is fragile, convention-less, and invisible to the framework.
Neither option is good. Password managers exist precisely to solve the problem of storing, rotating, and auditing access to secrets. Hanami should meet teams where they already are.
Value Proposition
Most development teams already pay for and use a password manager - for personal credentials, shared team secrets, and increasingly for application secrets too. 1Password, Bitwarden, and others have invested heavily in CLI tooling and SDKs specifically for this use case (1Password’s op CLI, Bitwarden Secrets Manager, etc.).
By adding first-class password manager support to Hanami Settings, we:
- Eliminate secret sprawl. Secrets live in one auditable, access-controlled place - not in
.envfiles, CI variables, shell history, or deploy scripts. - Leverage existing infrastructure. Teams don’t need to adopt a new tool. If they already use 1Password for team passwords, they can use the same vault for app secrets.
- Make the secure path the easy path. Today the easiest thing is
ENV["SECRET"]. We want the easiest thing to be fetching from a proper secret store, with ENV as the fallback for non-sensitive config. - Enable secret rotation without redeployment. Password managers support versioning and rotation. Reading from them at boot time (rather than baking secrets into deploy artifacts) means rotating a secret is a vault operation, not a deploy.
- Improve the developer experience. New team members clone the repo, authenticate with their password manager, and
hanami serverjust works - no hunting for the right.envfile or asking a colleague to DM them credentials.
Current Architecture
Today, Hanami::Settings reads all values through a single global store configured via config.settings_store, which defaults to EnvStore:
Hanami::Config
setting :settings_store, default: Hanami::Settings::EnvStore.new
│
▼
┌──────────────────┐
│ EnvStore │
│ #fetch(name) │
│ delegates to ENV │
└──────────────────┘
│
▼
Hanami::Settings#initialize
calls store.fetch(name)
for each defined setting
EnvStore#fetch(name) uppercases the setting name and looks it up in ENV. The store contract is simple: any object that implements #fetch with the same signature as Hash#fetch.
The single-store design means there’s no way to say “this setting comes from ENV, but that secret comes from 1Password” without replacing the entire store with a custom implementation.
Proposal
1. Password Manager Store Adapters
The headline feature: store adapters that fetch secrets from password managers. Each adapter wraps the provider’s CLI or SDK, accepting the structured arguments that provider requires (vault, item, field, account, etc.).
# config/settings.rb
module MyApp
class Settings < Hanami::Settings
# Non-sensitive config - still from ENV, no change
setting :database_url, constructor: Types::String
# Secrets - from 1Password
setting :session_secret,
constructor: Types::String,
store: Hanami::Settings::OnePasswordStore.new(
vault: "Production",
item: "MyApp Secrets"
)
# Secrets - from Bitwarden
setting :api_token,
constructor: Types::String,
store: Hanami::Settings::BitwardenStore.new(
organization_id: "org-uuid"
)
end
end
The setting name (session_secret, api_token) maps to the field/key name in the password manager. The adapter handles the rest: authenticating, locating the vault/item, and returning the value.
Planned Adapters
| Store | Backend | Required Config | Setting Name Mapping |
|---|---|---|---|
OnePasswordStore |
op CLI or Connect SDK |
vault, item |
setting name → field name |
BitwardenStore |
bw CLI or Secrets Manager SDK |
project_id or organization_id |
setting name → secret name |
LastPassStore |
lpass CLI |
folder (optional) |
setting name → entry field |
AWSSecretsManagerStore |
aws-sdk-secretsmanager |
secret_id, region |
setting name → JSON key in secret |
VaultStore |
vault CLI or HTTP API |
path |
setting name → key at path |
Example: OnePasswordStore Implementation
module Hanami
class Settings
class OnePasswordStore
def initialize(vault:, item:, account: nil, connect_host: nil, connect_token: nil)
@vault = vault
@item = item
@account = account
@connect_host = connect_host
@connect_token = connect_token
end
def fetch(name, *args, &block)
field = name.to_s
value = read_field(field)
if value.nil?
if args.length > 0
args.first
elsif block
block.call
else
raise KeyError, "field #{field.inspect} not found in #{@vault}/#{@item}"
end
else
value
end
end
private
def read_field(field)
if connect_mode?
read_via_connect(field)
else
read_via_cli(field)
end
end
def read_via_cli(field)
cmd = ["op", "read", "op://#{@vault}/#{@item}/#{field}"]
cmd += ["--account", @account] if @account
result = `#{cmd.shelljoin}`.strip
$?.success? ? result : nil
end
def connect_mode?
@connect_host && @connect_token
end
def read_via_connect(field)
# Use 1Password Connect REST API
# GET /v1/vaults/{vault_id}/items/{item_id} with field lookup
end
end
end
end
2. Per-Setting store: Option
To support password managers alongside ENV, individual settings need to declare which store resolves their value. This is a necessary change to the settings infrastructure - today all settings share a single global store.
Settings without an explicit store: continue to use the global settings_store exactly as they do today. This is fully backward compatible.
Changes to Hanami::Settings#initialize
def initialize(store = EMPTY_STORE)
errors = config._settings.each_with_object({}) do |setting, errs|
name = setting.name
# Use per-setting store if declared, otherwise the global store
effective_store = self.class.setting_stores[name] || store
value = effective_store.fetch(name, Undefined)
if value.eql?(Undefined)
public_send(name)
else
public_send("#{name}=", value)
end
rescue => exception
errs[name] = exception
end
raise InvalidSettingsError, errors if errors.any?
config.finalize!
end
Per-Setting Store Registry
Since dry-configurable’s setting DSL may not support passing through arbitrary options, we maintain a class-level registry mapping setting names to their stores:
class Settings < Hanami::Settings
class << self
def setting(name, store: nil, **options)
setting_stores[name] = store if store
super(name, **options)
end
def setting_stores
@setting_stores ||= {}
end
end
end
This keeps the public API clean - store: reads as a natural part of the setting declaration.
3. Composite Store (Optional Convenience)
For teams that want all secrets from one password manager without annotating every setting, a CompositeStore chains multiple stores with fallback semantics:
# config/app.rb
module MyApp
class App < Hanami::App
config.settings_store = Hanami::Settings::CompositeStore.new(
Hanami::Settings::EnvStore.new,
Hanami::Settings::OnePasswordStore.new(vault: "Production", item: "MyApp")
)
end
end
Resolution: try each store in order, return the first value found.
module Hanami
class Settings
class CompositeStore
def initialize(*stores)
@stores = stores
end
def fetch(name, *args, &block)
@stores.each do |store|
value = store.fetch(name, NOT_FOUND)
return value unless value.equal?(NOT_FOUND)
end
if args.length > 0
args.first
elsif block
block.call
else
raise KeyError, "setting not found: #{name}"
end
end
NOT_FOUND = Object.new.freeze
private_constant :NOT_FOUND
end
end
end
This is a convenience - not required. Teams can use per-setting store: exclusively, use CompositeStore exclusively, or mix both.
Migration Path
This is a purely additive change. No existing behavior changes.
- Default behavior is unchanged. Settings resolve from
EnvStoreviaconfig.settings_storeunless explicitly overridden. - Opt-in per setting. Add
store:to individual settings that should come from a password manager. - Opt-in composite. Replace
config.settings_storewith aCompositeStoreto layer ENV over a password manager globally. - Adapters are optional gems. No new dependencies are added to core Hanami.
Packaging Strategy
Password manager adapters ship as separate gems to avoid adding CLI/SDK dependencies to core Hanami:
| Gem | Contents |
|---|---|
hanami (core) |
CompositeStore, per-setting store: option |
hanami-settings-one_password |
OnePasswordStore adapter |
hanami-settings-bitwarden |
BitwardenStore adapter |
hanami-settings-lastpass |
LastPassStore adapter |
hanami-settings-aws_secrets_manager |
AWSSecretsManagerStore adapter |
hanami-settings-vault |
VaultStore (HashiCorp Vault) adapter |
Security Considerations
- No secrets in memory longer than necessary. Stores fetch values at boot time only. Settings are frozen after
config.finalize!. - CLI subprocess safety. Store adapters that shell out must use
shelljoin/ array-form execution to prevent command injection. - Service account tokens. Adapters must support non-interactive auth (service account tokens, Connect servers) for CI/CD environments where interactive login is not possible.
- Audit logging. Adapters should log (at debug level) which vault/item/path they are reading from, but never log retrieved values.
Open Questions
-
Caching: Should password manager stores cache fetched values for the lifetime of the process, or re-fetch on each access? Proposal: fetch once at boot, consistent with how
EnvStorereadsENVonce duringSettings#initialize. -
dry-configurable metadata: Does
dry-configurable’ssettingDSL support passing through arbitrary keyword options that can be read back? If so, the class-level registry can be avoided. If not, the registry approach described above is the fallback. -
Field name mapping: Should adapters support explicit field name overrides (e.g., setting
database_urlmaps to 1Password fieldDatabase URL)?setting :database_url, constructor: Types::String, store: op_store, store_key: "Database URL" -
Environment-specific vaults: Should there be a convention for mapping
Hanami.envto different vaults/items, or should users handle this inconfig/app.rb? -
Adapter gem naming:
hanami-settings-<provider>vshanami-<provider>- the former is more specific and avoids namespace collisions.
Feedback Welcome
This RFC is intentionally opinionated in places to give the discussion something concrete to react to, but we’re open to changing any part of it. Some areas where community input would be especially valuable:
- API ergonomics. Does
store:as a keyword onsettingfeel right? Would a block-based DSL, a separatesecretmethod, or something else entirely be more natural? For example:# Alternative: dedicated `secret` method instead of `store:` on `setting` secret :session_secret, from: :one_password, vault: "Production", item: "MyApp" # Alternative: block-based grouping from_one_password(vault: "Production", item: "MyApp") do setting :session_secret, constructor: Types::String setting :api_key, constructor: Types::String end - Which providers matter most? We’ve listed five adapters. Which ones would you actually use? Are there others we’re missing?
- The store contract. Is
#fetch(name, *default, &block)sufficient, or do adapters need richer context (e.g., the setting object itself, the current environment)? - Composite vs. per-setting. Is one approach clearly better, or is having both worth the added surface area?
- Anything we haven’t thought of. Edge cases, deployment patterns, security concerns - we want to hear it.
If you have thoughts, please comment on this RFC or open a discussion. The goal is to land on an API that feels like it belongs in Hanami - simple, clear, and unsurprising.
References
- 1Password CLI (
op) - 1Password Connect
- Bitwarden CLI (
bw) - Bitwarden Secrets Manager
- LastPass CLI (
lpass) - AWS Secrets Manager
- HashiCorp Vault
- dry-configurable
- Current implementation:
lib/hanami/settings.rb,lib/hanami/settings/env_store.rb