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 2cd9d32..edd0246 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -34,19 +34,109 @@ 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") 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) 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"