Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions internal/cac/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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(".", "_"))

Expand All @@ -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)

Expand All @@ -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
}
Expand Down
94 changes: 92 additions & 2 deletions internal/cac/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions internal/cac/config/testdata/profile_env_conflict.yaml
Original file line number Diff line number Diff line change
@@ -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"
Loading