mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-27 15:37:03 +00:00
feat(backend): Support _FILE suffix on sensitive env variables (#63)
* feat(env): add _FILE suffix support for env variables * fix(frontend): dynamic refresh on key creation * chore(deps): update packages * fix(test): coverage
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
@@ -188,6 +190,13 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
// Env vars override config file values
|
||||
bindEnvVars()
|
||||
|
||||
// Resolve `_FILE`-suffixed env vars for sensitive values (e.g.
|
||||
// {ENV}_FILE=/run/secrets/foo). Must run after bindEnvVars so the
|
||||
// warning about both forms being set fires correctly.
|
||||
if err := applyFileBackedEnvVars(); err != nil {
|
||||
return nil, fmt.Errorf("error resolving _FILE env vars: %w", err)
|
||||
}
|
||||
|
||||
// Read the config file (optional - will use defaults and env vars if not found)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
@@ -276,6 +285,50 @@ func bindEnvVars() {
|
||||
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
|
||||
}
|
||||
|
||||
// fileBackedEnvVars maps env var names to viper config keys for variables that
|
||||
// support the `_FILE` suffix convention. Operators may set `{ENV}_FILE` to a
|
||||
// file path; the file's contents (with trailing whitespace trimmed) become the
|
||||
// effective value. This pattern is used by Docker Official Images (postgres,
|
||||
// mysql) to inject secrets via mounted files instead of plain env vars,
|
||||
// avoiding exposure through `docker inspect`, process listings, or crash logs.
|
||||
//
|
||||
// Scope is intentionally limited to values that an operator would reasonably
|
||||
// store in a Kubernetes Secret or Docker secret. Non-sensitive config (host,
|
||||
// port, endpoints, etc.) is excluded.
|
||||
var fileBackedEnvVars = map[string]string{
|
||||
"GARAGE_UI_GARAGE_ADMIN_TOKEN": "garage.admin_token",
|
||||
"GARAGE_UI_AUTH_ADMIN_USERNAME": "auth.admin.username",
|
||||
"GARAGE_UI_AUTH_ADMIN_PASSWORD": "auth.admin.password",
|
||||
"GARAGE_UI_AUTH_JWT_PRIVATE_KEY": "auth.jwt_private_key",
|
||||
"GARAGE_UI_AUTH_OIDC_CLIENT_ID": "auth.oidc.client_id",
|
||||
"GARAGE_UI_AUTH_OIDC_CLIENT_SECRET": "auth.oidc.client_secret",
|
||||
}
|
||||
|
||||
// applyFileBackedEnvVars resolves `_FILE`-suffixed env vars listed in
|
||||
// fileBackedEnvVars. For each entry where `{ENV}_FILE` is set, the file is
|
||||
// read and its contents (trimmed of trailing CR/LF) become the value via
|
||||
// viper.Set, which is the highest-priority source — so a `_FILE` value wins
|
||||
// over both `{ENV}` and YAML. A missing or unreadable file is a hard error.
|
||||
func applyFileBackedEnvVars() error {
|
||||
for envVar, configKey := range fileBackedEnvVars {
|
||||
path := os.Getenv(envVar + "_FILE")
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s_FILE (%s): %w", envVar, path, err)
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logger.Warn().
|
||||
Str("env", envVar).
|
||||
Msg("both VAR and VAR_FILE are set; VAR_FILE takes precedence")
|
||||
}
|
||||
viper.Set(configKey, strings.TrimRight(string(data), "\r\n"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server config
|
||||
|
||||
@@ -511,6 +511,129 @@ func TestEffectiveAdminRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeSecretFile is a test helper that writes content to a temp file and
|
||||
// returns the absolute path. Uses t.TempDir so cleanup is automatic.
|
||||
func writeSecretFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "secret")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp secret: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestApplyFileBackedEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVar string
|
||||
configKey string
|
||||
fileBody string
|
||||
alsoSetEnv string
|
||||
useMissingFile bool
|
||||
wantValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "reads value from file",
|
||||
envVar: "GARAGE_UI_AUTH_ADMIN_PASSWORD",
|
||||
configKey: "auth.admin.password",
|
||||
fileBody: "s3cret",
|
||||
wantValue: "s3cret",
|
||||
},
|
||||
{
|
||||
name: "trims trailing newline",
|
||||
envVar: "GARAGE_UI_GARAGE_ADMIN_TOKEN",
|
||||
configKey: "garage.admin_token",
|
||||
fileBody: "tok\n",
|
||||
wantValue: "tok",
|
||||
},
|
||||
{
|
||||
name: "trims trailing CRLF",
|
||||
envVar: "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET",
|
||||
configKey: "auth.oidc.client_secret",
|
||||
fileBody: "secret\r\n",
|
||||
wantValue: "secret",
|
||||
},
|
||||
{
|
||||
name: "_FILE wins over plain env var",
|
||||
envVar: "GARAGE_UI_AUTH_ADMIN_USERNAME",
|
||||
configKey: "auth.admin.username",
|
||||
fileBody: "from-file",
|
||||
alsoSetEnv: "from-env",
|
||||
wantValue: "from-file",
|
||||
},
|
||||
{
|
||||
name: "missing file returns error",
|
||||
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
|
||||
configKey: "auth.jwt_private_key",
|
||||
useMissingFile: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiline PEM preserved internally, only trailing whitespace trimmed",
|
||||
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
|
||||
configKey: "auth.jwt_private_key",
|
||||
fileBody: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n",
|
||||
wantValue: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resetViper(t)
|
||||
|
||||
if tc.useMissingFile {
|
||||
t.Setenv(tc.envVar+"_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
} else {
|
||||
path := writeSecretFile(t, tc.fileBody)
|
||||
t.Setenv(tc.envVar+"_FILE", path)
|
||||
}
|
||||
if tc.alsoSetEnv != "" {
|
||||
t.Setenv(tc.envVar, tc.alsoSetEnv)
|
||||
}
|
||||
|
||||
err := applyFileBackedEnvVars()
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := viper.GetString(tc.configKey); got != tc.wantValue {
|
||||
t.Fatalf("viper.GetString(%q) = %q, want %q", tc.configKey, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileBackedEnvVars_NoFileEnvSet_NoOp(t *testing.T) {
|
||||
resetViper(t)
|
||||
if err := applyFileBackedEnvVars(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := viper.GetString("auth.admin.password"); got != "" {
|
||||
t.Fatalf("expected empty password, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
|
||||
resetViper(t)
|
||||
yamlPath := writeConfigFile(t, minimalValidYAML)
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
|
||||
_, err := Load(yamlPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Load when _FILE points at a missing file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "error resolving _FILE env vars") {
|
||||
t.Errorf("error %q does not contain wrapped prefix from Load", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
|
||||
Reference in New Issue
Block a user