|
| 1 | +// Package config provides configuration loading from Azure Key Vault. |
| 2 | +// |
| 3 | +// If SECRETS_VAULT_NAME is set, LoadSecretsFromKeyVault fetches all secrets |
| 4 | +// from the vault and maps them to environment variables. The mapping is: |
| 5 | +// |
| 6 | +// Key Vault secret name → Environment variable |
| 7 | +// server-database-url → OPENSANDBOX_DATABASE_URL |
| 8 | +// server-jwt-secret → OPENSANDBOX_JWT_SECRET |
| 9 | +// worker-s3-secret-key → OPENSANDBOX_S3_SECRET_ACCESS_KEY |
| 10 | +// ...etc |
| 11 | +// |
| 12 | +// Secrets already set in the environment are NOT overwritten — env vars take |
| 13 | +// precedence over Key Vault. This allows local overrides for development. |
| 14 | +// |
| 15 | +// Authentication uses Azure Default Credential (Managed Identity on VMs, |
| 16 | +// CLI credentials locally). No explicit credentials needed. |
| 17 | +package config |
| 18 | + |
| 19 | +import ( |
| 20 | + "context" |
| 21 | + "fmt" |
| 22 | + "log" |
| 23 | + "os" |
| 24 | + "strings" |
| 25 | + "time" |
| 26 | + |
| 27 | + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" |
| 28 | + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" |
| 29 | +) |
| 30 | + |
| 31 | +// secretMapping maps Key Vault secret names to environment variable names. |
| 32 | +// Only secrets in this map are loaded — unknown secrets in the vault are ignored. |
| 33 | +var secretMapping = map[string]string{ |
| 34 | + // Server secrets |
| 35 | + "server-database-url": "OPENSANDBOX_DATABASE_URL", |
| 36 | + "server-redis-url": "OPENSANDBOX_REDIS_URL", |
| 37 | + "server-jwt-secret": "OPENSANDBOX_JWT_SECRET", |
| 38 | + "server-api-key": "OPENSANDBOX_API_KEY", |
| 39 | + "server-secret-encryption-key": "OPENSANDBOX_SECRET_ENCRYPTION_KEY", |
| 40 | + "server-workos-api-key": "WORKOS_API_KEY", |
| 41 | + "server-workos-client-id": "WORKOS_CLIENT_ID", |
| 42 | + "server-cf-api-token": "OPENSANDBOX_CF_API_TOKEN", |
| 43 | + "server-cf-zone-id": "OPENSANDBOX_CF_ZONE_ID", |
| 44 | + "server-stripe-secret-key": "STRIPE_SECRET_KEY", |
| 45 | + "server-stripe-webhook-secret": "STRIPE_WEBHOOK_SECRET", |
| 46 | + |
| 47 | + // Worker secrets |
| 48 | + "worker-jwt-secret": "OPENSANDBOX_JWT_SECRET", |
| 49 | + "worker-database-url": "OPENSANDBOX_DATABASE_URL", |
| 50 | + "worker-redis-url": "OPENSANDBOX_REDIS_URL", |
| 51 | + "worker-s3-access-key": "OPENSANDBOX_S3_ACCESS_KEY_ID", |
| 52 | + "worker-s3-secret-key": "OPENSANDBOX_S3_SECRET_ACCESS_KEY", |
| 53 | + |
| 54 | + // Shared |
| 55 | + "pg-password": "OPENSANDBOX_PG_PASSWORD", |
| 56 | +} |
| 57 | + |
| 58 | +// LoadSecretsFromKeyVault fetches secrets from Azure Key Vault and sets them |
| 59 | +// as environment variables. Only loads secrets relevant to the current mode |
| 60 | +// (server or worker), determined by the secret name prefix. |
| 61 | +// |
| 62 | +// Skips secrets that are already set in the environment. |
| 63 | +// Does nothing if SECRETS_VAULT_NAME is not set. |
| 64 | +func LoadSecretsFromKeyVault() error { |
| 65 | + vaultName := os.Getenv("SECRETS_VAULT_NAME") |
| 66 | + if vaultName == "" { |
| 67 | + return nil // Key Vault not configured — use env file as-is |
| 68 | + } |
| 69 | + |
| 70 | + vaultURL := fmt.Sprintf("https://%s.vault.azure.net/", vaultName) |
| 71 | + mode := os.Getenv("OPENSANDBOX_MODE") // "server" or "worker" |
| 72 | + |
| 73 | + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 74 | + defer cancel() |
| 75 | + |
| 76 | + cred, err := azidentity.NewDefaultAzureCredential(nil) |
| 77 | + if err != nil { |
| 78 | + return fmt.Errorf("keyvault: azure credential: %w", err) |
| 79 | + } |
| 80 | + |
| 81 | + client, err := azsecrets.NewClient(vaultURL, cred, nil) |
| 82 | + if err != nil { |
| 83 | + return fmt.Errorf("keyvault: client: %w", err) |
| 84 | + } |
| 85 | + |
| 86 | + loaded := 0 |
| 87 | + skipped := 0 |
| 88 | + |
| 89 | + pager := client.NewListSecretPropertiesPager(nil) |
| 90 | + for pager.More() { |
| 91 | + page, err := pager.NextPage(ctx) |
| 92 | + if err != nil { |
| 93 | + return fmt.Errorf("keyvault: list secrets: %w", err) |
| 94 | + } |
| 95 | + |
| 96 | + for _, prop := range page.Value { |
| 97 | + name := prop.ID.Name() |
| 98 | + envVar, mapped := secretMapping[name] |
| 99 | + if !mapped { |
| 100 | + continue |
| 101 | + } |
| 102 | + |
| 103 | + // Only load secrets matching the current mode (or shared secrets) |
| 104 | + if mode != "" && !strings.HasPrefix(name, mode+"-") && !strings.HasPrefix(name, "pg-") { |
| 105 | + continue |
| 106 | + } |
| 107 | + |
| 108 | + // Don't overwrite existing env vars — local config takes precedence |
| 109 | + if os.Getenv(envVar) != "" { |
| 110 | + skipped++ |
| 111 | + continue |
| 112 | + } |
| 113 | + |
| 114 | + // Fetch the secret value |
| 115 | + resp, err := client.GetSecret(ctx, name, "", nil) |
| 116 | + if err != nil { |
| 117 | + log.Printf("keyvault: failed to get secret %s: %v (skipping)", name, err) |
| 118 | + continue |
| 119 | + } |
| 120 | + if resp.Value == nil { |
| 121 | + continue |
| 122 | + } |
| 123 | + |
| 124 | + os.Setenv(envVar, *resp.Value) |
| 125 | + loaded++ |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + log.Printf("keyvault: loaded %d secrets from %s (%d skipped, already set)", loaded, vaultName, skipped) |
| 130 | + return nil |
| 131 | +} |
0 commit comments