[RFC] First Class Password Manager Support in Hanami Settings

!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:

  1. Put secrets in ENV anyway. This means secrets end up in .env files checked into repos, pasted into CI dashboards, scattered across deploy configs, and visible to anyone who can run printenv on a server. ENV was designed for configuration, not secrets management.

  2. 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 .env files, 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 server just works - no hunting for the right .env file 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.

  1. Default behavior is unchanged. Settings resolve from EnvStore via config.settings_store unless explicitly overridden.
  2. Opt-in per setting. Add store: to individual settings that should come from a password manager.
  3. Opt-in composite. Replace config.settings_store with a CompositeStore to layer ENV over a password manager globally.
  4. 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

  1. 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 EnvStore reads ENV once during Settings#initialize.

  2. dry-configurable metadata: Does dry-configurable’s setting DSL 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.

  3. Field name mapping: Should adapters support explicit field name overrides (e.g., setting database_url maps to 1Password field Database URL)?

    setting :database_url,
      constructor: Types::String,
      store: op_store,
      store_key: "Database URL"
    
  4. Environment-specific vaults: Should there be a convention for mapping Hanami.env to different vaults/items, or should users handle this in config/app.rb?

  5. Adapter gem naming: hanami-settings-<provider> vs hanami-<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 on setting feel right? Would a block-based DSL, a separate secret method, 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

I do have some mild concern that this is not within scope for Hanami or it’s maintainers. It could certainly be argued that we should allow an individual gem developer to shoulder the maintenance burden here. While I will acknowledge that this is a very valid position to take I would like to take a moment and really hammer in the value proposition here:

  1. This is a common pain point, look at projects like fnox by jdx that also try to solve this very issue.
  2. The CLIs and APIs that power these password managers are pretty dang stable for the most part. I don’t forsee a whole lot of churn beyond the initial push.
  3. No other web frameworks in ruby that I am aware of are offering this out of the box.

Thanks for writing it down. I like the idea of challenging the status quo, but I find few things in this proposal a bit puzzling for now.

  1. I honestly don’t know anyone who uses 1Password in production. In my books this is a developer tool. It’s fine to make a dev workflow less painful, in fact this is where I would like to see Hanami go more often. But a significant part of this proposal addresses production concerns. Is this really a thing that people are using password managers in production (actual password managers, because I think Vault and AWS Secrets Manager are not password managers per se)?
  2. If we are talking about dev experience, tying the setting to a store at application configuration might work for many cases, but not for all. For example: a company provides 1Password for its employees, but also hires contractors. These contractors work on the same codebase, but use Bitwarden for passwords management, because this is their standard and they are not added to the main 1Password. I believer the proposed composite store coudl somehow address this, but this requires the code to represent every dev tool people are using, which feels a bit backwards.
  3. I’m not yet sure how I feel about coupling application boot with external service’s availability. Might be okay, but raises some pink (lighter than red) flag for me.

There are also some statements in the proposal with which I don’t agree.

This seems like a false dichotomy to me. There are other options. In fact, .env files are conventionally NOT checked into the repos. Orgs sometimes have scripts/tasks to pull secrets from their password manager to .env file and it works fine. Doesn’t require custom glue code on application boot nor checking in `.env to the repo.

Transferring values from secret stores to environment (in production, not dev) also always felt like a more-or-less solved problem to me. And it’s a DevOps problem, not a one that application frameworks necessarily needs to solve IMO.

If secrets are fetched at boot time, they are kept in memory for the whole application process lifetime. Not sure how “no longer than necessary” applies here. It would work if the secret was pulled from the store every time, but this option was rejected below.

All in all, I do like the idea of allowing to have a different settings source than the ENV. Then indeed particular integration (1Password, LastPass) could be maintained by the community.

I read about fnox the other week and my though was that indeed this is something that could work nicely with Hanami. However, from my superficial analysis this looks more akin to Rails’ secrets.yml than tight integration with password managers. Maybe first it would be nice to gather community input about which approach is more in demand?

1 Like

Thanks for the thoughtful proposal, @aaronmallen. And thanks @katafrakt for your great feedback, too.

My feeling is that this is the perfect kind of thing to live as a community-provided extension to Hanami. My hope in building a flexible framework core is that things like this become possible, and can then flourish behind folks who are enthusiastic about maintaining them.

I see this kind of thing as an essential next step in our growth as a community, so I’m glad you’re helping to make it happen. If you run into anything you can’t achieve with the framework as it is, please let us know.

In terms of the changes you’ve suggested for the settings API, I’d suggest taking the approach of the composite store instead of marking individual settings for specific stores. The composite store you should already be able to achieve using the existing API.

1 Like

I have started working on a gem for this: