diff --git a/.env.example b/.env.example index ff2b920..a40821f 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,12 @@ DB_DRIVER=postgres DB_CONNECTION_STRING=postgres://user:password@localhost:5432/mydb?sslmode=disable DB_MAX_OPEN_CONNECTIONS=25 DB_MAX_IDLE_CONNECTIONS=5 -DB_CONN_MAX_LIFETIME=5 +DB_CONN_MAX_LIFETIME_MINUTES=5 # Server configuration SERVER_HOST=0.0.0.0 SERVER_PORT=8080 +SERVER_SHUTDOWN_TIMEOUT_SECONDS=10 # Logging LOG_LEVEL=info @@ -18,55 +19,9 @@ LOG_LEVEL=info # Metrics configuration METRICS_ENABLED=true METRICS_NAMESPACE=secrets +METRICS_PORT=8081 -# Master keys (Envelope Encryption) -# All deployments REQUIRE KMS mode - master keys must be encrypted at rest using external Key Management Service -# Generate a new KMS master key using: ./bin/app create-master-key --kms-provider= --kms-key-uri= -# Rotate master keys using: ./bin/app rotate-master-key --id= -# -# 🔒 SECURITY WARNING: KMS_KEY_URI is HIGHLY SENSITIVE -# - Controls access to ALL encrypted data in this deployment -# - NEVER commit KMS_KEY_URI to source control (even private repos) -# - Store in secrets manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault) -# - Use .env files excluded from git (.env is in .gitignore) -# - Inject via CI/CD secrets for automated deployments -# - NEVER use base64key:// provider in staging or production (local development only) -# - Rotate KMS keys quarterly or per organizational policy -# - See docs/configuration.md#kms_key_uri for incident response procedures -# -# KMS Providers: -# - localsecrets: Local testing ONLY (base64key://<32-byte-base64-key>) ❌ DO NOT USE IN PRODUCTION -# - gcpkms: Google Cloud KMS (gcpkms://projects//locations//keyRings//cryptoKeys/) -# - awskms: AWS KMS (awskms:/// or awskms:///) -# - azurekeyvault: Azure Key Vault (azurekeyvault://.vault.azure.net/keys/) -# - hashivault: HashiCorp Vault (hashivault:///) -# -# === LOCAL DEVELOPMENT (localsecrets provider - INSECURE, DEVELOPMENT ONLY) === -# For local development, use the localsecrets provider with a base64-encoded 32-byte key -KMS_PROVIDER=localsecrets -KMS_KEY_URI=base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4= -MASTER_KEYS=default:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04 -ACTIVE_MASTER_KEY_ID=default -# -# === PRODUCTION EXAMPLES (commented - uncomment and configure for your environment) === -# -# GCP KMS Configuration: -# KMS_PROVIDER=gcpkms -# KMS_KEY_URI=gcpkms://projects/my-prod-project/locations/us-central1/keyRings/secrets-keyring/cryptoKeys/master-key -# MASTER_KEYS=default:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04 -# ACTIVE_MASTER_KEY_ID=default -# -# AWS KMS Configuration: -# KMS_PROVIDER=awskms -# KMS_KEY_URI=awskms:///alias/secrets-master-key -# MASTER_KEYS=default:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04 -# ACTIVE_MASTER_KEY_ID=default -# -# Azure Key Vault Configuration: -# KMS_PROVIDER=azurekeyvault -# KMS_KEY_URI=azurekeyvault://my-prod-vault.vault.azure.net/keys/secrets-master-key -# MASTER_KEYS=default:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04 -# ACTIVE_MASTER_KEY_ID=default +# ... # Authentication configuration # Token expiration in seconds (default: 14400 = 4 hours) @@ -86,6 +41,11 @@ RATE_LIMIT_TOKEN_ENABLED=true RATE_LIMIT_TOKEN_REQUESTS_PER_SEC=5.0 RATE_LIMIT_TOKEN_BURST=10 +# Account Lockout +LOCKOUT_MAX_ATTEMPTS=10 +LOCKOUT_DURATION_MINUTES=30 + + # CORS configuration # ⚠️ SECURITY WARNING: CORS is disabled by default for server-to-server API # Enable only if browser-based access is required diff --git a/docs/configuration.md b/docs/configuration.md index 948b223..2c6638d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # ⚙️ Environment Variables -> Last updated: 2026-02-28 +> Last updated: 2026-03-02 Secrets is configured through environment variables. @@ -12,11 +12,12 @@ DB_DRIVER=postgres DB_CONNECTION_STRING=postgres://user:password@localhost:5432/mydb?sslmode=disable DB_MAX_OPEN_CONNECTIONS=25 DB_MAX_IDLE_CONNECTIONS=5 -DB_CONN_MAX_LIFETIME=5 +DB_CONN_MAX_LIFETIME_MINUTES=5 # Server configuration SERVER_HOST=0.0.0.0 SERVER_PORT=8080 +SERVER_SHUTDOWN_TIMEOUT_SECONDS=10 LOG_LEVEL=info # Master key configuration (KMS mode required as of v0.19.0) @@ -98,7 +99,7 @@ Maximum number of open database connections (default: `25`). Maximum number of idle database connections (default: `5`). -### DB_CONN_MAX_LIFETIME +### DB_CONN_MAX_LIFETIME_MINUTES Maximum lifetime of a connection in minutes (default: `5`). @@ -110,11 +111,15 @@ Host address to bind the HTTP server (default: `0.0.0.0`). ### SERVER_PORT -Port to bind the HTTP server (default: `8080`). +Port to bind the HTTP server (default: `8080`). Must be between 1 and 65535 and different from `METRICS_PORT`. + +### SERVER_SHUTDOWN_TIMEOUT_SECONDS + +Maximum time to wait for the server to gracefully shutdown in seconds (default: `10`). ### LOG_LEVEL -Logging level. Supported values: `debug`, `info`, `warn`, `error` (default: `info`). +Logging level. Supported values: `debug`, `info`, `warn`, `error`, `fatal`, `panic` (default: `info`). ## Master key configuration diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 7a13b68..e6b7827 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -1,6 +1,6 @@ # 🐳 Run with Docker (Recommended) -> Last updated: 2026-02-28 +> Last updated: 2026-03-02 This is the default way to run Secrets. @@ -181,10 +181,11 @@ DB_DRIVER=postgres DB_CONNECTION_STRING=postgres://user:password@secrets-postgres:5432/mydb?sslmode=disable DB_MAX_OPEN_CONNECTIONS=25 DB_MAX_IDLE_CONNECTIONS=5 -DB_CONN_MAX_LIFETIME=5 +DB_CONN_MAX_LIFETIME_MINUTES=5 SERVER_HOST=0.0.0.0 SERVER_PORT=8080 +SERVER_SHUTDOWN_TIMEOUT_SECONDS=10 LOG_LEVEL=info # KMS Configuration (required in v0.19.0+) @@ -206,6 +207,7 @@ RATE_LIMIT_TOKEN_BURST=10 METRICS_ENABLED=true METRICS_NAMESPACE=secrets +METRICS_PORT=8081 EOF ``` diff --git a/docs/operations/deployment/database-scaling.md b/docs/operations/deployment/database-scaling.md index 55963a7..c4abd9b 100644 --- a/docs/operations/deployment/database-scaling.md +++ b/docs/operations/deployment/database-scaling.md @@ -40,6 +40,9 @@ As your Secrets deployment grows, you may encounter: | **Database CPU** | > 70% | Vertical scaling (larger instance) or read replicas | | **Disk IOPS** | > 80% of provisioned | Increase IOPS or use faster storage | +> Last updated: 2026-03-02 +... + ## Connection Pooling ### Built-in Connection Pool @@ -50,7 +53,7 @@ Secrets uses `database/sql` connection pooling (Go standard library): # Environment variables for connection pooling DB_MAX_OPEN_CONNECTIONS=25 # Max connections to database (default: 25) DB_MAX_IDLE_CONNECTIONS=5 # Max idle connections in pool (default: 5) -DB_CONN_MAX_LIFETIME=5 # Max connection lifetime in minutes (default: 5 min) +DB_CONN_MAX_LIFETIME_MINUTES=5 # Max connection lifetime in minutes (default: 5 min) ``` ### Tuning Guidelines diff --git a/internal/config/config.go b/internal/config/config.go index fd22bac..18d7114 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,14 +2,44 @@ package config import ( + "fmt" "os" "path/filepath" "time" "github.com/allisson/go-env" + validation "github.com/jellydator/validation" "github.com/joho/godotenv" ) +// Default configuration values. +const ( + DefaultServerHost = "0.0.0.0" + DefaultServerPort = 8080 + DefaultServerShutdownTimeout = 10 // seconds + DefaultDBDriver = "postgres" + DefaultDBConnectionString = "postgres://user:password@localhost:5432/mydb?sslmode=disable" //nolint:gosec + DefaultDBMaxOpenConnections = 25 + + DefaultDBMaxIdleConnections = 5 + DefaultDBConnMaxLifetime = 5 // minutes + DefaultLogLevel = "info" + DefaultAuthTokenExpiration = 14400 // seconds + DefaultRateLimitEnabled = true + DefaultRateLimitRequests = 10.0 + DefaultRateLimitBurst = 20 + DefaultRateLimitTokenEnabled = true + DefaultRateLimitTokenRequests = 5.0 + DefaultRateLimitTokenBurst = 10 + DefaultCORSEnabled = false + DefaultCORSAllowOrigins = "" + DefaultMetricsEnabled = true + DefaultMetricsNamespace = "secrets" + DefaultMetricsPort = 8081 + DefaultLockoutMaxAttempts = 10 + DefaultLockoutDuration = 30 // minutes +) + // Config holds all application configuration. type Config struct { // ServerHost is the host address the server will bind to. @@ -30,7 +60,7 @@ type Config struct { // DBConnMaxLifetime is the maximum amount of time a connection may be reused. DBConnMaxLifetime time.Duration - // LogLevel is the logging level (e.g., "debug", "info", "warn", "error"). + // LogLevel is the logging level (e.g., "debug", "info", "warn", "error", "fatal", "panic"). LogLevel string // AuthTokenExpiration is the duration after which an authentication token expires. @@ -62,7 +92,7 @@ type Config struct { // MetricsPort is the port number for the metrics server. MetricsPort int - // KMSProvider is the KMS provider to use (e.g., "google", "aws", "azure"). + // KMSProvider is the KMS provider to use (e.g., "google", "aws", "azure", "hashivault", "localsecrets"). KMSProvider string // KMSKeyURI is the URI for the master key in the KMS. KMSKeyURI string @@ -73,60 +103,115 @@ type Config struct { LockoutDuration time.Duration } +// Validate checks if the configuration is valid. +func (c *Config) Validate() error { + return validation.ValidateStruct( + c, + validation.Field(&c.DBDriver, validation.Required, validation.In("postgres", "mysql")), + validation.Field(&c.DBConnectionString, validation.Required), + validation.Field(&c.ServerPort, validation.Required, validation.Min(1), validation.Max(65535)), + validation.Field( + &c.MetricsPort, + validation.Required, + validation.Min(1), + validation.Max(65535), + validation.NotIn(c.ServerPort), + ), + validation.Field( + &c.LogLevel, + validation.Required, + validation.In("debug", "info", "warn", "error", "fatal", "panic"), + ), + validation.Field(&c.KMSProvider, validation.When(c.KMSKeyURI != "", validation.Required)), + validation.Field(&c.KMSKeyURI, validation.When(c.KMSProvider != "", validation.Required)), + validation.Field( + &c.RateLimitRequestsPerSec, + validation.When(c.RateLimitEnabled, validation.Required, validation.Min(0.1)), + ), + validation.Field( + &c.RateLimitTokenRequestsPerSec, + validation.When(c.RateLimitTokenEnabled, validation.Required, validation.Min(0.1)), + ), + ) +} + // Load loads configuration from environment variables and .env file. func Load() *Config { // Try to load .env file recursively loadDotEnv() - return &Config{ + cfg := &Config{ // Server configuration - ServerHost: env.GetString("SERVER_HOST", "0.0.0.0"), - ServerPort: env.GetInt("SERVER_PORT", 8080), - ServerShutdownTimeout: env.GetDuration("SERVER_SHUTDOWN_TIMEOUT", 10, time.Second), + ServerHost: env.GetString("SERVER_HOST", DefaultServerHost), + ServerPort: env.GetInt("SERVER_PORT", DefaultServerPort), + ServerShutdownTimeout: env.GetDuration( + "SERVER_SHUTDOWN_TIMEOUT_SECONDS", + DefaultServerShutdownTimeout, + time.Second, + ), // Database configuration - DBDriver: env.GetString("DB_DRIVER", "postgres"), + DBDriver: env.GetString("DB_DRIVER", DefaultDBDriver), DBConnectionString: env.GetString( "DB_CONNECTION_STRING", - "postgres://user:password@localhost:5432/mydb?sslmode=disable", + DefaultDBConnectionString, + ), + DBMaxOpenConnections: env.GetInt("DB_MAX_OPEN_CONNECTIONS", DefaultDBMaxOpenConnections), + DBMaxIdleConnections: env.GetInt("DB_MAX_IDLE_CONNECTIONS", DefaultDBMaxIdleConnections), + DBConnMaxLifetime: env.GetDuration( + "DB_CONN_MAX_LIFETIME_MINUTES", + DefaultDBConnMaxLifetime, + time.Minute, ), - DBMaxOpenConnections: env.GetInt("DB_MAX_OPEN_CONNECTIONS", 25), - DBMaxIdleConnections: env.GetInt("DB_MAX_IDLE_CONNECTIONS", 5), - DBConnMaxLifetime: env.GetDuration("DB_CONN_MAX_LIFETIME", 5, time.Minute), // Logging - LogLevel: env.GetString("LOG_LEVEL", "info"), + LogLevel: env.GetString("LOG_LEVEL", DefaultLogLevel), // Auth - AuthTokenExpiration: env.GetDuration("AUTH_TOKEN_EXPIRATION_SECONDS", 14400, time.Second), + AuthTokenExpiration: env.GetDuration( + "AUTH_TOKEN_EXPIRATION_SECONDS", + DefaultAuthTokenExpiration, + time.Second, + ), // Rate Limiting (authenticated endpoints) - RateLimitEnabled: env.GetBool("RATE_LIMIT_ENABLED", true), - RateLimitRequestsPerSec: env.GetFloat64("RATE_LIMIT_REQUESTS_PER_SEC", 10.0), - RateLimitBurst: env.GetInt("RATE_LIMIT_BURST", 20), + RateLimitEnabled: env.GetBool("RATE_LIMIT_ENABLED", DefaultRateLimitEnabled), + RateLimitRequestsPerSec: env.GetFloat64("RATE_LIMIT_REQUESTS_PER_SEC", DefaultRateLimitRequests), + RateLimitBurst: env.GetInt("RATE_LIMIT_BURST", DefaultRateLimitBurst), // Rate Limiting for Token Endpoint (IP-based, unauthenticated) - RateLimitTokenEnabled: env.GetBool("RATE_LIMIT_TOKEN_ENABLED", true), - RateLimitTokenRequestsPerSec: env.GetFloat64("RATE_LIMIT_TOKEN_REQUESTS_PER_SEC", 5.0), - RateLimitTokenBurst: env.GetInt("RATE_LIMIT_TOKEN_BURST", 10), + RateLimitTokenEnabled: env.GetBool("RATE_LIMIT_TOKEN_ENABLED", DefaultRateLimitTokenEnabled), + RateLimitTokenRequestsPerSec: env.GetFloat64( + "RATE_LIMIT_TOKEN_REQUESTS_PER_SEC", + DefaultRateLimitTokenRequests, + ), + RateLimitTokenBurst: env.GetInt("RATE_LIMIT_TOKEN_BURST", DefaultRateLimitTokenBurst), // CORS - CORSEnabled: env.GetBool("CORS_ENABLED", false), - CORSAllowOrigins: env.GetString("CORS_ALLOW_ORIGINS", ""), + CORSEnabled: env.GetBool("CORS_ENABLED", DefaultCORSEnabled), + CORSAllowOrigins: env.GetString("CORS_ALLOW_ORIGINS", DefaultCORSAllowOrigins), // Metrics - MetricsEnabled: env.GetBool("METRICS_ENABLED", true), - MetricsNamespace: env.GetString("METRICS_NAMESPACE", "secrets"), - MetricsPort: env.GetInt("METRICS_PORT", 8081), + MetricsEnabled: env.GetBool("METRICS_ENABLED", DefaultMetricsEnabled), + MetricsNamespace: env.GetString("METRICS_NAMESPACE", DefaultMetricsNamespace), + MetricsPort: env.GetInt("METRICS_PORT", DefaultMetricsPort), // KMS configuration KMSProvider: env.GetString("KMS_PROVIDER", ""), KMSKeyURI: env.GetString("KMS_KEY_URI", ""), // Account Lockout - LockoutMaxAttempts: env.GetInt("LOCKOUT_MAX_ATTEMPTS", 10), - LockoutDuration: env.GetDuration("LOCKOUT_DURATION_MINUTES", 30, time.Minute), + LockoutMaxAttempts: env.GetInt("LOCKOUT_MAX_ATTEMPTS", DefaultLockoutMaxAttempts), + LockoutDuration: env.GetDuration("LOCKOUT_DURATION_MINUTES", DefaultLockoutDuration, time.Minute), + } + + // Validate configuration + if err := cfg.Validate(); err != nil { + fmt.Printf("configuration validation failed: %v\n", err) + os.Exit(1) } + + return cfg } // GetGinMode returns the appropriate Gin mode based on log level. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8402d51..364b0cb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,133 @@ import ( "github.com/stretchr/testify/require" ) +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr bool + }{ + { + name: "valid config", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "info", + RateLimitEnabled: true, + RateLimitRequestsPerSec: 10, + RateLimitTokenEnabled: true, + RateLimitTokenRequestsPerSec: 5, + }, + wantErr: false, + }, + { + name: "invalid db driver", + cfg: &Config{ + DBDriver: "sqlite", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "info", + }, + wantErr: true, + }, + { + name: "missing db connection string", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "info", + }, + wantErr: true, + }, + { + name: "invalid server port", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 70000, + MetricsPort: 8081, + LogLevel: "info", + }, + wantErr: true, + }, + { + name: "conflicting ports", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8080, + LogLevel: "info", + }, + wantErr: true, + }, + { + name: "invalid log level", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "trace", + }, + wantErr: true, + }, + { + name: "missing KMS provider when key URI is present", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "info", + KMSKeyURI: "gcpkms://...", + }, + wantErr: true, + }, + { + name: "missing KMS key URI when provider is present", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "info", + KMSProvider: "google", + }, + wantErr: true, + }, + { + name: "invalid rate limit requests", + cfg: &Config{ + DBDriver: "postgres", + DBConnectionString: "postgres://localhost", + ServerPort: 8080, + MetricsPort: 8081, + LogLevel: "info", + RateLimitEnabled: true, + RateLimitRequestsPerSec: 0, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestLoad(t *testing.T) { tests := []struct { name string @@ -45,22 +172,24 @@ func TestLoad(t *testing.T) { { name: "load custom server configuration", envVars: map[string]string{ - "SERVER_HOST": "localhost", - "SERVER_PORT": "9090", + "SERVER_HOST": "localhost", + "SERVER_PORT": "9090", + "SERVER_SHUTDOWN_TIMEOUT_SECONDS": "20", }, validate: func(t *testing.T, cfg *Config) { assert.Equal(t, "localhost", cfg.ServerHost) assert.Equal(t, 9090, cfg.ServerPort) + assert.Equal(t, 20*time.Second, cfg.ServerShutdownTimeout) }, }, { name: "load custom database configuration", envVars: map[string]string{ - "DB_DRIVER": "mysql", - "DB_CONNECTION_STRING": "user:password@tcp(localhost:3306)/testdb", - "DB_MAX_OPEN_CONNECTIONS": "50", - "DB_MAX_IDLE_CONNECTIONS": "10", - "DB_CONN_MAX_LIFETIME": "10", + "DB_DRIVER": "mysql", + "DB_CONNECTION_STRING": "user:password@tcp(localhost:3306)/testdb", + "DB_MAX_OPEN_CONNECTIONS": "50", + "DB_MAX_IDLE_CONNECTIONS": "10", + "DB_CONN_MAX_LIFETIME_MINUTES": "10", }, validate: func(t *testing.T, cfg *Config) { assert.Equal(t, "mysql", cfg.DBDriver) @@ -178,6 +307,8 @@ func TestLoad(t *testing.T) { } // Load configuration + // We can't easily test Load() directly if it calls os.Exit(1). + // However, for valid test cases it should work fine. cfg := Load() // Validate @@ -218,7 +349,13 @@ func TestLoadDotEnv(t *testing.T) { }() // Create a .env file in the temp root - err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("TEST_ENV_VAR=found"), 0600) + err = os.WriteFile( + filepath.Join(tmpDir, ".env"), + []byte( + "TEST_ENV_VAR=found\nDB_DRIVER=postgres\nDB_CONNECTION_STRING=postgres://localhost\nSERVER_PORT=8080\nMETRICS_PORT=8081\nLOG_LEVEL=info", + ), + 0600, + ) require.NoError(t, err) // Create a child directory