Dev-only: SecretsManagerClient loses configuration after code reload, breaking all OpenBao calls (500 on group Secrets page)

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Summary

In development mode (GDK), SecretsManagement::SecretsManagerClient loses its configuration after any Rails code reload. Every subsequent OpenBao call raises ArgumentError: bad argument (expected URI object or URI string).

The client memoizes its configuration in a class-level instance variable:

# ee/lib/secrets_management/secrets_manager_client.rb
def self.configuration
  @configuration ||= Configuration.new
end

The configuration (host and base path) is set exactly once at boot by ee/config/initializers/secrets_manager.rb. But ee/lib is a Zeitwerk-managed reloadable autoload path. In development, any code change or branch switch triggers a reload that discards and redefines the class. The new class object has no @configuration, so the next configuration call memoizes a fresh Configuration.new with host = nil. Initializers do not run again after boot, so nothing repopulates it. The next request then effectively calls URI.join(nil, ...) and raises the ArgumentError.

This only affects development. Production and test eager-load classes and never reload, so the boot-time configuration lives for the process lifetime. Only GDK developers hit this.

The impact got worse with commit 61106c4f0aff ("Secrets Manager: enforce granular permissions on the backend", merged 2026-07-10). Since then, Groups::SecretsController runs check_read_capability!, which calls SecretsManagement::UserPermissions::EffectiveCapabilitiesService. That service synchronously logs into OpenBao (JWT login) during the HTML page load, with no rescue. After a reload, visiting a group's Secrets page returns a full 500 error page. Before that commit, the failure only surfaced through the openbaoHealth GraphQL query, which rescues errors and shows a "Failed to connect with OpenBao" alert instead.

For history: the memoized configure/configuration pattern was introduced in commit 21e07e933143 (2024-12-20). The initializer has existed since 7002ef70c656 (2024-10-28).

Steps to reproduce

In GDK, development mode:

  1. Boot GDK with the Secrets Manager / OpenBao set up and a group where the secrets manager is active.
  2. Trigger a code reload: edit any Ruby file under an autoload path, or switch git branches.
  3. Visit the group's Secrets page (/groups/<group>/-/secrets/).

What is the current bug behavior?

The request fails with a 500 error page: ArgumentError in Groups::SecretsController#index — bad argument (expected URI object or URI string). All OpenBao calls fail the same way until the Rails process is restarted (gdk restart rails-web).

What is the expected correct behavior?

OpenBao calls keep working across dev-mode code reloads, the same as after a fresh boot.

Relevant logs and/or screenshots

Top frames of the stack trace:

uri (1.1.1) lib/uri/rfc3986_parser.rb:200:in `convert_to_uri'
uri (1.1.1) lib/uri/rfc3986_parser.rb:139:in `join'
uri (1.1.1) lib/uri/common.rb:274:in `join'
ee/lib/secrets_management/secrets_manager_client.rb:414:in `token_connection'
ee/lib/secrets_management/secrets_manager_client.rb:397:in `login_with_jwt'
ee/lib/secrets_management/secrets_manager_client.rb:314:in `capabilities'
ee/app/services/secrets_management/user_permissions/effective_capabilities_service.rb:48:in `execute'
ee/app/controllers/groups/secrets_controller.rb:52:in `check_read_capability!'

Possible fixes

Wrap the existing initializer body in Rails.application.config.to_prepare. It runs once at boot in production and test, so there is no behavior change there, and it re-runs after every reload in development. This is the canonical Rails approach for initializers that configure reloadable constants; the Rails autoloading guide warns against configuring reloadable classes from plain initializers.

# ee/config/initializers/secrets_manager.rb
Rails.application.config.to_prepare do
  SecretsManagement::SecretsManagerClient.configure do |c|
    c.host = if Rails.env.test?
               "http://127.0.0.1:9800"
             else
               SecretsManagement::ProjectSecretsManager.internal_server_url
             end

    c.base_path = 'v1'
  end
end

An alternative, larger fix: make Configuration compute lazy defaults at read time so the class self-heals after a reload, and delete the initializer. The to_prepare change is the minimal fix.

Edited by 🤖 GitLab Bot 🤖