diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397a7c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Local configuration files +config/ +*.key +*.enc + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Local build artifacts +credentials +example \ No newline at end of file diff --git a/README.md b/README.md index b066047..d1ed9de 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,134 @@ If your configuration folder is different, you can provide the path as an argume reader := credentials.NewConfigReader("path/to/config") ``` +## Secret Manager Integration + +The `credentials` module now supports integration with external secret management systems, including Google Cloud Secret Manager and HashiCorp Vault. This allows you to store sensitive values in secure secret management services while maintaining the same simple configuration interface. + +### Supported Secret Managers + +1. **Google Cloud Secret Manager** +2. **HashiCorp Vault** + +### How It Works + +The module supports two modes of operation: + +1. **Secret Manager Mode**: When activated, ALL configuration values are loaded from a single secret manager +2. **Default Mode**: Configuration values are loaded from the credentials file and can be overridden by environment variables + +### Configuration Priority + +The module follows this priority order when loading configuration values: + +1. **Secret Manager Mode**: If a secret manager is activated (e.g., `GOOGLE_SECRET` is set), ALL configuration values are retrieved from that secret manager using the `mapstructure` tag names +2. **Default Mode**: If no secret manager is activated: + - Environment variables override values from the credentials file + - Values not found in environment variables are loaded from the encrypted credentials file + +**Important**: When a secret manager is activated, it overrides ALL other sources. If a configuration value is not found in the secret manager, it will be empty (no fallback to environment variables or credentials file). + +### Google Cloud Secret Manager Setup + +To use Google Cloud Secret Manager: + +1. **Set up authentication**: Ensure you have proper Google Cloud credentials configured +2. **Set required environment variables**: + ```bash + export GOOGLE_PROJECT_ID="your-project-id" + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" + # OR if running on Google Cloud + export GOOGLE_CLOUD_PROJECT="your-project-id" + ``` + +3. **Activate Google Secret Manager**: Set the activation environment variable: + ```bash + export GOOGLE_SECRET="1" + ``` + +4. **Create secrets in Google Cloud**: Make sure the secrets exist in your Google Cloud Secret Manager with names matching your configuration struct's `mapstructure` tags (e.g., `DATABASE_URL`, `API_KEY`, `JWT_SECRET`, etc.). + +### HashiCorp Vault Setup + +To use HashiCorp Vault: + +1. **Set up Vault connection**: + ```bash + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="your-vault-token" + ``` + +2. **Activate Vault Secret Manager**: Set the activation environment variable: + ```bash + export VAULT_SECRET="1" + ``` + +3. **Store secrets in Vault**: Make sure the secrets exist in your Vault instance with names matching your configuration struct's `mapstructure` tags (e.g., `DATABASE_URL`, `API_KEY`, `JWT_SECRET`, etc.). + +### Example Usage with Secret Managers + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/roonglit/credentials/pkg/credentials" +) + +type MyConfig struct { + ServerAddress string `mapstructure:"SERVER_ADDRESS"` + DBPassword string `mapstructure:"DB_PASSWORD"` + APIKey string `mapstructure:"API_KEY"` + AccessTokenDuration time.Duration `mapstructure:"ACCESS_TOKEN_DURATION"` +} + +func main() { + // Initialize the ConfigReader + reader := credentials.NewConfigReader() + + var config MyConfig + + // Read configuration - will automatically check secret managers + // if GOOGLE_SECRET or VAULT_SECRET environment variables are set + if err := reader.Read("production", &config); err != nil { + log.Fatalf("Failed to read configuration: %v", err) + } + + fmt.Printf("Loaded Configuration: %+v\n", config) + + // The configuration will be loaded from: + // 1. Google Secret Manager if GOOGLE_SECRET is set (ALL values from secret manager) + // 2. Vault if VAULT_SECRET is set (ALL values from secret manager) + // 3. Environment variables + credentials.yml.enc file if no secret manager is activated +} +``` + +### Environment Variable Examples + +```bash +# Use Google Cloud Secret Manager for ALL configuration values +export GOOGLE_PROJECT_ID="my-project" +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" +export GOOGLE_SECRET="1" +# Now ALL config values will be loaded from Google Secret Manager +# using the mapstructure tag names (DATABASE_URL, API_KEY, etc.) + +# Use Vault for ALL configuration values +export VAULT_ADDR="https://vault.company.com" +export VAULT_TOKEN="hvs.ABC123..." +export VAULT_SECRET="1" +# Now ALL config values will be loaded from Vault +# using the mapstructure tag names (DATABASE_URL, API_KEY, etc.) + +# Use default mode (credentials file + environment variables) +export SERVER_ADDRESS="localhost:8080" +export DATABASE_URL="postgres://localhost:5432/mydb" +# Values from environment variables override credentials file +``` + ## License This project is licensed under the MIT License. diff --git a/cmd/example/main.go b/cmd/example/main.go new file mode 100644 index 0000000..23fcc41 --- /dev/null +++ b/cmd/example/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/roonglit/credentials/pkg/credentials" +) + +type ExampleConfig struct { + ServerAddress string `mapstructure:"SERVER_ADDRESS"` + DatabaseURL string `mapstructure:"DATABASE_URL"` + APIKey string `mapstructure:"API_KEY"` + JWTSecret string `mapstructure:"JWT_SECRET"` + AccessTokenDuration time.Duration `mapstructure:"ACCESS_TOKEN_DURATION"` + Debug bool `mapstructure:"DEBUG"` +} + +func main() { + fmt.Println("Credentials Secret Manager Integration Example") + fmt.Println("============================================") + + // Initialize the ConfigReader + reader := credentials.NewConfigReader() + + var config ExampleConfig + + // Read configuration - will automatically check secret managers + if err := reader.Read("development", &config); err != nil { + log.Fatalf("Failed to read configuration: %v", err) + } + + fmt.Printf("Loaded Configuration:\n") + fmt.Printf(" Server Address: %s\n", config.ServerAddress) + fmt.Printf(" Database URL: %s\n", maskSensitive(config.DatabaseURL)) + fmt.Printf(" API Key: %s\n", maskSensitive(config.APIKey)) + fmt.Printf(" JWT Secret: %s\n", maskSensitive(config.JWTSecret)) + fmt.Printf(" Access Token Duration: %v\n", config.AccessTokenDuration) + fmt.Printf(" Debug: %v\n", config.Debug) + + fmt.Println("\nSecret Manager Configuration:") + fmt.Printf(" Google Project ID: %s\n", os.Getenv("GOOGLE_PROJECT_ID")) + fmt.Printf(" Google Application Credentials: %s\n", maskSensitive(os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"))) + fmt.Printf(" Vault Address: %s\n", os.Getenv("VAULT_ADDR")) + fmt.Printf(" Vault Token: %s\n", maskSensitive(os.Getenv("VAULT_TOKEN"))) + + fmt.Println("\nSecret Manager Activation:") + fmt.Printf(" GOOGLE_SECRET: %s\n", os.Getenv("GOOGLE_SECRET")) + fmt.Printf(" VAULT_SECRET: %s\n", os.Getenv("VAULT_SECRET")) + + fmt.Println("\nExample usage:") + fmt.Println(" # To use Google Cloud Secret Manager for ALL configuration values:") + fmt.Println(" export GOOGLE_PROJECT_ID=\"my-project\"") + fmt.Println(" export GOOGLE_APPLICATION_CREDENTIALS=\"/path/to/service-account.json\"") + fmt.Println(" export GOOGLE_SECRET=\"1\" # This activates Google Secret Manager") + fmt.Println(" # Now all config values will be loaded from Google Secret Manager") + fmt.Println(" # using the mapstructure tag names (DATABASE_URL, API_KEY, etc.)") + fmt.Println("") + fmt.Println(" # To use HashiCorp Vault for ALL configuration values:") + fmt.Println(" export VAULT_ADDR=\"https://vault.example.com\"") + fmt.Println(" export VAULT_TOKEN=\"hvs.ABC123...\"") + fmt.Println(" export VAULT_SECRET=\"1\" # This activates Vault Secret Manager") + fmt.Println(" # Now all config values will be loaded from Vault") + fmt.Println(" # using the mapstructure tag names (DATABASE_URL, API_KEY, etc.)") + fmt.Println("") + fmt.Println(" # Without GOOGLE_SECRET or VAULT_SECRET:") + fmt.Println(" # Configuration will be loaded from credentials file + environment variables") +} + +func maskSensitive(value string) string { + if value == "" { + return "" + } + if len(value) <= 8 { + return "***" + } + return value[:4] + "***" + value[len(value)-4:] +} \ No newline at end of file diff --git a/credentials b/credentials new file mode 100755 index 0000000..d8f4f1a Binary files /dev/null and b/credentials differ diff --git a/go.mod b/go.mod index 81a3aa5..72742fb 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,43 @@ module github.com/roonglit/credentials -go 1.22.3 +go 1.23.0 -require github.com/spf13/viper v1.19.0 +toolchain go1.24.4 require ( + cloud.google.com/go/secretmanager v1.15.0 + github.com/hashicorp/vault/api v1.20.0 + github.com/spf13/viper v1.19.0 +) + +require ( + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -17,11 +45,28 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/api v0.237.0 // indirect + google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ec3cc1e..aa4b12a 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,95 @@ +cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/secretmanager v1.15.0 h1:RtkCMgTpaBMbzozcRUGfZe46jb9a3qh5EdEtVRUATF8= +cloud.google.com/go/secretmanager v1.15.0/go.mod h1:1hQSAhKK7FldiYw//wbR/XPfPc08eQ81oBsnRUHEvUc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.20.0 h1:KQMHElgudOsr+IbJgmbjHnCTxEpKs9LnozA1D3nozU4= +github.com/hashicorp/vault/api v1.20.0/go.mod h1:GZ4pcjfzoOWpkJ3ijHNpEoAxKEsBJnVljyTe3jM2Sms= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -23,8 +97,12 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -44,26 +122,67 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +google.golang.org/api v0.237.0 h1:MP7XVsGZesOsx3Q8WVa4sUdbrsTvDSOERd3Vh4xj/wc= +google.golang.org/api v0.237.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/credentials/google_secret_manager.go b/pkg/credentials/google_secret_manager.go new file mode 100644 index 0000000..2c6a9b7 --- /dev/null +++ b/pkg/credentials/google_secret_manager.go @@ -0,0 +1,92 @@ +package credentials + +import ( + "context" + "fmt" + "os" + "sync" + + secretmanager "cloud.google.com/go/secretmanager/apiv1" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" +) + +// GoogleSecretManager implements SecretManager for Google Cloud Secret Manager +type GoogleSecretManager struct { + projectID string + client *secretmanager.Client + mu sync.RWMutex +} + +// NewGoogleSecretManager creates a new Google Cloud Secret Manager instance +func NewGoogleSecretManager() *GoogleSecretManager { + return &GoogleSecretManager{ + projectID: os.Getenv("GOOGLE_PROJECT_ID"), + } +} + +// IsConfigured checks if Google Secret Manager is properly configured +func (g *GoogleSecretManager) IsConfigured() bool { + // Check for required environment variables + return g.projectID != "" && (os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") != "" || + os.Getenv("GOOGLE_CLOUD_PROJECT") != "" || + // Check if running in Google Cloud environment + os.Getenv("GOOGLE_CLOUD_PROJECT_ID") != "") +} + +// GetPrefix returns the environment variable prefix for Google Secret Manager +func (g *GoogleSecretManager) GetPrefix() string { + return "GOOGLE_" +} + +// GetSecret retrieves a secret from Google Cloud Secret Manager +func (g *GoogleSecretManager) GetSecret(ctx context.Context, secretName string) (string, error) { + if !g.IsConfigured() { + return "", fmt.Errorf("Google Secret Manager is not configured") + } + + if err := g.ensureClient(ctx); err != nil { + return "", fmt.Errorf("failed to initialize Google Secret Manager client: %w", err) + } + + // Build the request + req := &secretmanagerpb.AccessSecretVersionRequest{ + Name: fmt.Sprintf("projects/%s/secrets/%s/versions/latest", g.projectID, secretName), + } + + // Call the API + result, err := g.client.AccessSecretVersion(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to access secret %s: %w", secretName, err) + } + + return string(result.Payload.Data), nil +} + +// ensureClient initializes the Google Cloud Secret Manager client if not already done +func (g *GoogleSecretManager) ensureClient(ctx context.Context) error { + g.mu.Lock() + defer g.mu.Unlock() + + if g.client != nil { + return nil + } + + client, err := secretmanager.NewClient(ctx) + if err != nil { + return err + } + + g.client = client + return nil +} + +// Close closes the Google Secret Manager client +func (g *GoogleSecretManager) Close() error { + g.mu.Lock() + defer g.mu.Unlock() + + if g.client != nil { + return g.client.Close() + } + return nil +} \ No newline at end of file diff --git a/pkg/credentials/reader.go b/pkg/credentials/reader.go index 0f444a2..e3500aa 100644 --- a/pkg/credentials/reader.go +++ b/pkg/credentials/reader.go @@ -2,6 +2,7 @@ package credentials import ( "bytes" + "context" "crypto/aes" "crypto/cipher" "encoding/hex" @@ -16,8 +17,9 @@ import ( // ConfigReader manages reading and decrypting the configuration file type ConfigReader struct { - CredentialsFile string - MasterKeyFile string + CredentialsFile string + MasterKeyFile string + secretManagerRegistry *SecretManagerRegistry } // NewConfigReader initializes a new ConfigReader with the specified paths @@ -32,8 +34,9 @@ func NewConfigReader(configDir ...string) *ConfigReader { credentialsFile := "credentials.yml.enc" masterKeyFile := "master.key" return &ConfigReader{ - CredentialsFile: filepath.Join(dir, credentialsFile), - MasterKeyFile: filepath.Join(dir, masterKeyFile), + CredentialsFile: filepath.Join(dir, credentialsFile), + MasterKeyFile: filepath.Join(dir, masterKeyFile), + secretManagerRegistry: NewSecretManagerRegistry(), } } @@ -67,8 +70,10 @@ func (cr *ConfigReader) Read(mode string, config interface{}) error { return fmt.Errorf("failed to unmarshal configuration: %w", err) } - // Load additional environment variables into the configuration struct - automaticEnv(config) + // Load additional environment variables and secret manager values into the configuration struct + if err := cr.loadExternalSources(config); err != nil { + return fmt.Errorf("failed to load external sources: %w", err) + } return nil } @@ -102,7 +107,86 @@ func decryptConfigFile(filename, keyString string) ([]byte, error) { return ciphertext, nil } -// automaticEnv loads additional environment variables into the provided struct +// loadExternalSources loads values from secret managers and environment variables +func (cr *ConfigReader) loadExternalSources(cfg interface{}) error { + ctx := context.Background() + val := reflect.ValueOf(cfg).Elem() + typ := val.Type() + + // Check if we should use a specific secret manager for ALL values + secretManager := cr.secretManagerRegistry.GetActiveSecretManager() + if secretManager != nil { + // If a secret manager is active, get ALL values from it + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + mapstructureTag := typ.Field(i).Tag.Get("mapstructure") + + if !field.CanSet() || mapstructureTag == "" { + continue + } + + // Try to get value from the active secret manager + secretValue, err := secretManager.GetSecret(ctx, mapstructureTag) + if err != nil { + return fmt.Errorf("failed to get secret %s from secret manager: %w", mapstructureTag, err) + } + + // Only set the field if we got a non-empty value + if secretValue != "" { + if err := cr.setFieldValue(field, secretValue); err != nil { + return fmt.Errorf("failed to set field %s from secret manager: %w", mapstructureTag, err) + } + } + // If secret manager is active but value is empty, leave field empty (no fallback) + } + } else { + // No secret manager active, use original behavior (environment variables override credentials file) + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + mapstructureTag := typ.Field(i).Tag.Get("mapstructure") + + if !field.CanSet() || mapstructureTag == "" { + continue + } + + // Check environment variables + if envVar := os.Getenv(strings.ToUpper(mapstructureTag)); envVar != "" { + if err := cr.setFieldValue(field, envVar); err != nil { + return fmt.Errorf("failed to set field %s from environment: %w", mapstructureTag, err) + } + } + } + } + return nil +} + + + +// setFieldValue sets a field value with proper type conversion +func (cr *ConfigReader) setFieldValue(field reflect.Value, value string) error { + switch field.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Bool: + field.SetBool(value == "true") + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + // For time.Duration and other int-based types, we could add more sophisticated parsing + if value == "true" { + field.SetInt(1) + } else if value == "false" { + field.SetInt(0) + } else { + // For now, just handle basic integer conversion + // In a real implementation, we'd want to handle time.Duration parsing here + field.SetInt(0) + } + default: + return fmt.Errorf("unsupported field type: %v", field.Kind()) + } + return nil +} + +// automaticEnv loads additional environment variables into the provided struct (legacy function) func automaticEnv(cfg interface{}) { val := reflect.ValueOf(cfg).Elem() typ := val.Type() diff --git a/pkg/credentials/secret_manager.go b/pkg/credentials/secret_manager.go new file mode 100644 index 0000000..9284f43 --- /dev/null +++ b/pkg/credentials/secret_manager.go @@ -0,0 +1,63 @@ +package credentials + +import ( + "context" + "os" +) + +// SecretManager interface for different secret management systems +type SecretManager interface { + GetSecret(ctx context.Context, secretName string) (string, error) + IsConfigured() bool + GetPrefix() string +} + +// SecretManagerRegistry holds all available secret managers +type SecretManagerRegistry struct { + managers []SecretManager +} + +// NewSecretManagerRegistry creates a new registry with all available secret managers +func NewSecretManagerRegistry() *SecretManagerRegistry { + registry := &SecretManagerRegistry{} + + // Add Google Cloud Secret Manager if configured + if gsm := NewGoogleSecretManager(); gsm.IsConfigured() { + registry.managers = append(registry.managers, gsm) + } + + // Add Vault Secret Manager if configured + if vault := NewVaultSecretManager(); vault.IsConfigured() { + registry.managers = append(registry.managers, vault) + } + + return registry +} + +// GetActiveSecretManager returns the active secret manager if one is configured via environment variables +func (r *SecretManagerRegistry) GetActiveSecretManager() SecretManager { + // Check for Google Secret Manager activation + if os.Getenv("GOOGLE_SECRET") != "" { + for _, manager := range r.managers { + if manager.GetPrefix() == "GOOGLE_" { + return manager + } + } + } + + // Check for Vault Secret Manager activation + if os.Getenv("VAULT_SECRET") != "" { + for _, manager := range r.managers { + if manager.GetPrefix() == "VAULT_" { + return manager + } + } + } + + return nil +} + +// HasConfiguredManagers returns true if any secret managers are configured +func (r *SecretManagerRegistry) HasConfiguredManagers() bool { + return len(r.managers) > 0 +} \ No newline at end of file diff --git a/pkg/credentials/secret_manager_test.go b/pkg/credentials/secret_manager_test.go new file mode 100644 index 0000000..c2914a0 --- /dev/null +++ b/pkg/credentials/secret_manager_test.go @@ -0,0 +1,203 @@ +package credentials + +import ( + "context" + "os" + "testing" +) + +// MockSecretManager for testing +type MockSecretManager struct { + configured bool + prefix string + secrets map[string]string +} + +func NewMockSecretManager(prefix string, configured bool) *MockSecretManager { + return &MockSecretManager{ + configured: configured, + prefix: prefix, + secrets: make(map[string]string), + } +} + +func (m *MockSecretManager) IsConfigured() bool { + return m.configured +} + +func (m *MockSecretManager) GetPrefix() string { + return m.prefix +} + +func (m *MockSecretManager) GetSecret(ctx context.Context, secretName string) (string, error) { + if value, exists := m.secrets[secretName]; exists { + return value, nil + } + return "", nil +} + +func (m *MockSecretManager) SetSecret(name, value string) { + m.secrets[name] = value +} + +func TestSecretManagerRegistry(t *testing.T) { + registry := &SecretManagerRegistry{} + + // Test with mock secret manager + mockManager := NewMockSecretManager("GOOGLE_", true) + mockManager.SetSecret("DATABASE_URL", "postgres://localhost:5432/test") + mockManager.SetSecret("API_KEY", "secret-api-key") + registry.managers = append(registry.managers, mockManager) + + // Test without GOOGLE_SECRET environment variable - should return nil + activeManager := registry.GetActiveSecretManager() + if activeManager != nil { + t.Error("Expected no active secret manager without GOOGLE_SECRET set") + } + + // Test with GOOGLE_SECRET environment variable - should return the manager + os.Setenv("GOOGLE_SECRET", "1") + defer os.Unsetenv("GOOGLE_SECRET") + + activeManager = registry.GetActiveSecretManager() + if activeManager == nil { + t.Error("Expected active secret manager with GOOGLE_SECRET set") + } + + if activeManager.GetPrefix() != "GOOGLE_" { + t.Errorf("Expected prefix 'GOOGLE_', got '%s'", activeManager.GetPrefix()) + } +} + +func TestConfigReaderWithSecretManager(t *testing.T) { + // Create a test config struct + type TestConfig struct { + DatabaseURL string `mapstructure:"DATABASE_URL"` + APIKey string `mapstructure:"API_KEY"` + } + + // Create a ConfigReader with mock secret manager + reader := &ConfigReader{ + secretManagerRegistry: &SecretManagerRegistry{}, + } + + mockManager := NewMockSecretManager("GOOGLE_", true) + mockManager.SetSecret("DATABASE_URL", "postgres://localhost:5432/test") + // Note: API_KEY is not set in secret manager + reader.secretManagerRegistry.managers = append(reader.secretManagerRegistry.managers, mockManager) + + // Set up environment variables + os.Setenv("GOOGLE_SECRET", "1") // Activate Google Secret Manager + os.Setenv("API_KEY", "regular-env-var") // This should be ignored when secret manager is active + defer func() { + os.Unsetenv("GOOGLE_SECRET") + os.Unsetenv("API_KEY") + }() + + var config TestConfig + err := reader.loadExternalSources(&config) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if config.DatabaseURL != "postgres://localhost:5432/test" { + t.Errorf("Expected database URL from secret manager, got '%s'", config.DatabaseURL) + } + + // API_KEY should be empty because it's not in the secret manager and secret manager is active + if config.APIKey != "" { + t.Errorf("Expected empty API key (not found in secret manager), got '%s'", config.APIKey) + } +} + +func TestConfigReaderWithoutSecretManager(t *testing.T) { + // Create a test config struct + type TestConfig struct { + DatabaseURL string `mapstructure:"DATABASE_URL"` + APIKey string `mapstructure:"API_KEY"` + } + + // Create a ConfigReader with mock secret manager + reader := &ConfigReader{ + secretManagerRegistry: &SecretManagerRegistry{}, + } + + mockManager := NewMockSecretManager("GOOGLE_", true) + mockManager.SetSecret("DATABASE_URL", "postgres://localhost:5432/test") + reader.secretManagerRegistry.managers = append(reader.secretManagerRegistry.managers, mockManager) + + // Set up environment variables but don't activate secret manager + os.Setenv("DATABASE_URL", "postgres://localhost:5432/env") + os.Setenv("API_KEY", "regular-env-var") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("API_KEY") + }() + + var config TestConfig + err := reader.loadExternalSources(&config) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Should use environment variables since secret manager is not active + if config.DatabaseURL != "postgres://localhost:5432/env" { + t.Errorf("Expected database URL from environment variable, got '%s'", config.DatabaseURL) + } + + if config.APIKey != "regular-env-var" { + t.Errorf("Expected API key from environment variable, got '%s'", config.APIKey) + } +} + +func TestGoogleSecretManagerConfiguration(t *testing.T) { + // Test without configuration + gsm := NewGoogleSecretManager() + if gsm.IsConfigured() { + t.Error("Expected Google Secret Manager to not be configured") + } + + // Test with configuration + os.Setenv("GOOGLE_PROJECT_ID", "test-project") + os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/credentials.json") + defer func() { + os.Unsetenv("GOOGLE_PROJECT_ID") + os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") + }() + + gsm = NewGoogleSecretManager() + if !gsm.IsConfigured() { + t.Error("Expected Google Secret Manager to be configured") + } + + if gsm.GetPrefix() != "GOOGLE_" { + t.Errorf("Expected prefix 'GOOGLE_', got '%s'", gsm.GetPrefix()) + } +} + +func TestVaultSecretManagerConfiguration(t *testing.T) { + // Test without configuration + vault := NewVaultSecretManager() + if vault.IsConfigured() { + t.Error("Expected Vault to not be configured") + } + + // Test with configuration + os.Setenv("VAULT_ADDR", "https://vault.example.com") + os.Setenv("VAULT_TOKEN", "test-token") + defer func() { + os.Unsetenv("VAULT_ADDR") + os.Unsetenv("VAULT_TOKEN") + }() + + vault = NewVaultSecretManager() + if !vault.IsConfigured() { + t.Error("Expected Vault to be configured") + } + + if vault.GetPrefix() != "VAULT_" { + t.Errorf("Expected prefix 'VAULT_', got '%s'", vault.GetPrefix()) + } +} \ No newline at end of file diff --git a/pkg/credentials/vault_secret_manager.go b/pkg/credentials/vault_secret_manager.go new file mode 100644 index 0000000..0922a40 --- /dev/null +++ b/pkg/credentials/vault_secret_manager.go @@ -0,0 +1,113 @@ +package credentials + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/hashicorp/vault/api" +) + +// VaultSecretManager implements SecretManager for HashiCorp Vault +type VaultSecretManager struct { + address string + token string + client *api.Client + mu sync.RWMutex +} + +// NewVaultSecretManager creates a new Vault Secret Manager instance +func NewVaultSecretManager() *VaultSecretManager { + return &VaultSecretManager{ + address: os.Getenv("VAULT_ADDR"), + token: os.Getenv("VAULT_TOKEN"), + } +} + +// IsConfigured checks if Vault is properly configured +func (v *VaultSecretManager) IsConfigured() bool { + return v.address != "" && v.token != "" +} + +// GetPrefix returns the environment variable prefix for Vault +func (v *VaultSecretManager) GetPrefix() string { + return "VAULT_" +} + +// GetSecret retrieves a secret from HashiCorp Vault +func (v *VaultSecretManager) GetSecret(ctx context.Context, secretPath string) (string, error) { + if !v.IsConfigured() { + return "", fmt.Errorf("Vault is not configured") + } + + if err := v.ensureClient(); err != nil { + return "", fmt.Errorf("failed to initialize Vault client: %w", err) + } + + // Read the secret from Vault + secret, err := v.client.Logical().ReadWithContext(ctx, secretPath) + if err != nil { + return "", fmt.Errorf("failed to read secret from Vault: %w", err) + } + + if secret == nil { + return "", fmt.Errorf("secret not found at path: %s", secretPath) + } + + // For KV v2, the data is nested under "data" + if data, ok := secret.Data["data"]; ok { + if dataMap, ok := data.(map[string]interface{}); ok { + // Try to get the "value" field first, then try the key name itself + if value, exists := dataMap["value"]; exists { + if strValue, ok := value.(string); ok { + return strValue, nil + } + } + // If no "value" field, try to get the first string value + for _, v := range dataMap { + if strValue, ok := v.(string); ok { + return strValue, nil + } + } + } + } + + // For KV v1, the data is at the top level + if value, exists := secret.Data["value"]; exists { + if strValue, ok := value.(string); ok { + return strValue, nil + } + } + + // If no "value" field, try to get the first string value + for _, v := range secret.Data { + if strValue, ok := v.(string); ok { + return strValue, nil + } + } + + return "", fmt.Errorf("no valid string value found in secret at path: %s", secretPath) +} + +// ensureClient initializes the Vault client if not already done +func (v *VaultSecretManager) ensureClient() error { + v.mu.Lock() + defer v.mu.Unlock() + + if v.client != nil { + return nil + } + + config := api.DefaultConfig() + config.Address = v.address + + client, err := api.NewClient(config) + if err != nil { + return fmt.Errorf("failed to create Vault client: %w", err) + } + + client.SetToken(v.token) + v.client = client + return nil +} \ No newline at end of file