From de49c69cbf0299fd990b667dc6d098fd88497459 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 12 Jun 2026 23:44:01 +0200 Subject: [PATCH 1/2] test: prove default/profile client credential conflict via env vars When both the default client and a profile client are configured through environment variables, the profile silently uses the default client's credentials instead of its own. Two coupled defects cause this: - profile client env vars (PROFILES__CLIENT_*) are never read, so the profile has no client of its own - a profile without its own client falls back to the default client by sharing the same pointer, so it inherits the default's credentials and is aliased to it Add two failing tests (and a fixture) that assert the correct behavior to document the bug. --- internal/cac/config/config_test.go | 90 +++++++++++++++++++ .../config/testdata/profile_env_conflict.yaml | 13 +++ 2 files changed, 103 insertions(+) create mode 100644 internal/cac/config/testdata/profile_env_conflict.yaml diff --git a/internal/cac/config/config_test.go b/internal/cac/config/config_test.go index 2cd9d32..364adb6 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -34,12 +34,102 @@ func TestReadingConfiguration(t *testing.T) { require.NotEmpty(t, "aaaaaaaaaaaaa", profile.Client.ClientID) }) + t.Run("prefers profile config over default config", func(t *testing.T) { + rootConf, err := config.InitConfig("./../../../examples/e2e/config.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + + profile, err := rootConf.ForProfile("stage") + require.NoError(t, err) + + // values defined in the profile take precedence over the default config + require.NotNil(t, profile.Client) + require.Equal(t, "https://janus.eu.authz.cloudentity.io/janus/system", profile.Client.IssuerURL.String()) + require.NotEqual(t, defaultConf.Client.IssuerURL.String(), profile.Client.IssuerURL.String()) + + require.Equal(t, "fb346c287c4d4e378cbae39aa0cxxxxx", profile.Client.ClientID) + require.NotEqual(t, defaultConf.Client.ClientID, profile.Client.ClientID) + + require.NotNil(t, profile.Storage) + require.Equal(t, []string{"/tmp/other"}, profile.Storage.DirPath) + require.NotEqual(t, defaultConf.Storage.DirPath, profile.Storage.DirPath) + + // values not overridden in the profile fall back to the default config + require.NotNil(t, profile.Logging) + require.Equal(t, defaultConf.Logging.Level, profile.Logging.Level) + require.Equal(t, defaultConf.Logging.Format, profile.Logging.Format) + }) + t.Run("fail on invalid path", func(t *testing.T) { _, err := config.InitConfig("./invalid.json") require.Error(t, err) require.Equal(t, "open ./invalid.json: no such file or directory", err.Error()) }) + // Reproduces a bug: when both the default client and a profile client are + // configured through environment variables, the profile silently ends up + // using the default client's credentials instead of its own. + t.Run("profile client from env does not collide with default client from env", func(t *testing.T) { + // default client credentials via env + t.Setenv("CLIENT_ISSUER_URL", "https://default.example.com/default/system") + t.Setenv("CLIENT_CLIENT_ID", "env-default-id") + t.Setenv("CLIENT_CLIENT_SECRET", "env-default-secret") + + // stage profile client credentials via env + t.Setenv("PROFILES_STAGE_CLIENT_ISSUER_URL", "https://stage.example.com/stage/system") + t.Setenv("PROFILES_STAGE_CLIENT_CLIENT_ID", "env-stage-id") + t.Setenv("PROFILES_STAGE_CLIENT_CLIENT_SECRET", "env-stage-secret") + + rootConf, err := config.InitConfig("./testdata/profile_env_conflict.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + + stage, err := rootConf.ForProfile("stage") + require.NoError(t, err) + + // sanity: default picks up its own env credentials + require.Equal(t, "env-default-id", defaultConf.Client.ClientID) + require.Equal(t, "env-default-secret", defaultConf.Client.ClientSecret) + + // the profile must use ITS OWN env credentials, not the default's + require.Equal(t, "env-stage-id", stage.Client.ClientID) + require.Equal(t, "env-stage-secret", stage.Client.ClientSecret) + require.Equal(t, "https://stage.example.com/stage/system", stage.Client.IssuerURL.String()) + + // the two clients must not be the same backing object + require.NotEqual(t, defaultConf.Client.ClientID, stage.Client.ClientID) + }) + + // Reproduces the mechanism behind the conflict above: a profile that does + // not define its own client falls back to the default client by sharing the + // SAME pointer, so mutating one mutates the other. + t.Run("profile without client is not aliased to the default client", func(t *testing.T) { + t.Setenv("CLIENT_ISSUER_URL", "https://default.example.com/default/system") + t.Setenv("CLIENT_CLIENT_ID", "env-default-id") + t.Setenv("CLIENT_CLIENT_SECRET", "env-default-secret") + + rootConf, err := config.InitConfig("./testdata/profile_env_conflict.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + + stage, err := rootConf.ForProfile("stage") + require.NoError(t, err) + + // the profile should fall back to the default values, but as an + // independent copy, not a shared pointer + require.NotSame(t, defaultConf.Client, stage.Client) + + stage.Client.ClientID = "mutated-via-stage" + require.Equal(t, "env-default-id", defaultConf.Client.ClientID, + "mutating the profile client must not affect the default client") + }) + t.Run("read config from env", func(t *testing.T) { t.Setenv("CLIENT_ISSUER_URL", "https://postmance.eu.authz.cloudentity.io/postmance/system") t.Setenv("CLIENT_CLIENT_ID", "test-cid1") diff --git a/internal/cac/config/testdata/profile_env_conflict.yaml b/internal/cac/config/testdata/profile_env_conflict.yaml new file mode 100644 index 0000000..2c9761b --- /dev/null +++ b/internal/cac/config/testdata/profile_env_conflict.yaml @@ -0,0 +1,13 @@ +# Default client and profile client credentials are intentionally NOT set here. +# They are meant to be provided via environment variables (secrets should not +# live in committed config). The profile only declares non-secret settings. +logging: + level: info + format: text +storage: + dir_path: "/tmp/default-data" + +profiles: + stage: + storage: + dir_path: "/tmp/stage-data" From 1190838ed9a75396359036b13ac39cc16f9d0f7f Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Sat, 13 Jun 2026 00:21:34 +0200 Subject: [PATCH 2/2] fix: resolve default/profile client credential conflict from env vars Two coupled defects caused a profile to silently use the default client's credentials when both were configured via environment variables: - profile client env vars (PROFILES__CLIENT_*) were never read, so a profile had no client of its own - a profile without its own client fell back to the default client by sharing the same pointer, aliasing the two configs Fixes: - bind each default config key per profile and promote any set profile env var into viper's explicit override layer, which is deep-merged on Unmarshal (AutomaticEnv/BindEnv values otherwise do not merge into nested maps that originate from a config file) - deep-copy the default sub-configs on fallback in SetImplicitValues so profiles never share or mutate the default's objects - use an isolated viper instance instead of the global singleton so repeated InitConfig calls do not leak explicit overrides into each other The previously failing tests now pass. --- internal/cac/config/config.go | 44 +++++++++++++++++++++++++----- internal/cac/config/config_test.go | 4 +-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/internal/cac/config/config.go b/internal/cac/config/config.go index e4abd6e..c7e4500 100644 --- a/internal/cac/config/config.go +++ b/internal/cac/config/config.go @@ -56,16 +56,21 @@ func (c *Configuration) SetImplicitValues(name string, defaultConfig Configurati c.Name = name } - if c.Logging == nil { - c.Logging = defaultConfig.Logging + // Fall back to the default config, but as an independent copy so that + // profiles never share (and accidentally mutate) the default's objects. + if c.Logging == nil && defaultConfig.Logging != nil { + clone := *defaultConfig.Logging + c.Logging = &clone } - if c.Client == nil { - c.Client = defaultConfig.Client + if c.Client == nil && defaultConfig.Client != nil { + clone := *defaultConfig.Client + c.Client = &clone } - if c.Storage == nil { - c.Storage = defaultConfig.Storage + if c.Storage == nil && defaultConfig.Storage != nil { + clone := *defaultConfig.Storage + c.Storage = &clone } } @@ -82,7 +87,7 @@ func InitConfig(path string) (_ *RootConfiguration, err error) { config.Default.SetImplicitValues("default", DefaultConfig()) utils.ConfigureDecoder(&dconf) - v := viper.GetViper() + v := viper.New() v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) @@ -98,6 +103,10 @@ func InitConfig(path string) (_ *RootConfiguration, err error) { v.SetDefault(k, val) } + // Leaf keys of the (squashed) default config, e.g. "client.client_id". + // These are the keys that can also be set per profile via env variables. + defaultKeys := v.AllKeys() + if path != "" { v.SetConfigFile(path) @@ -106,6 +115,27 @@ func InitConfig(path string) (_ *RootConfiguration, err error) { } } + // viper's AutomaticEnv only resolves keys it already knows about, and env + // bindings do not merge into nested maps that originate from a config file + // during Unmarshal. So for every known profile, bind each default config key + // to its env variable (e.g. PROFILES_STAGE_CLIENT_CLIENT_ID) and, when that + // variable is set, promote it into viper's explicit override layer which is + // deep-merged on Unmarshal. Profiles without env overrides are untouched and + // still fall back to the default config in SetImplicitValues. + for name := range v.GetStringMap("profiles") { + for _, key := range defaultKeys { + profileKey := "profiles." + name + "." + key + + if err := v.BindEnv(profileKey); err != nil { + return nil, err + } + + if v.IsSet(profileKey) { + v.Set(profileKey, v.Get(profileKey)) + } + } + } + if err := v.Unmarshal(&config, utils.ConfigureDecoder); err != nil { return nil, err } diff --git a/internal/cac/config/config_test.go b/internal/cac/config/config_test.go index 364adb6..edd0246 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -135,8 +135,8 @@ func TestReadingConfiguration(t *testing.T) { t.Setenv("CLIENT_CLIENT_ID", "test-cid1") t.Setenv("CLIENT_CLIENT_SECRET", "test-secret") - // FIXME reading profiles from env variables is not yet supported - // t.Setenv("PROFILES_STAGE_CLIENT_CLIENT_SECRET", "test-secret") + // Reading profile values from env variables is covered by + // "profile client from env does not collide with default client from env". rootConf, err := config.InitConfig("") require.NoError(t, err)