|
| 1 | +package secrets |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "context" |
| 6 | + "encoding/json" |
| 7 | + "fmt" |
| 8 | + "io" |
| 9 | + "net/http" |
| 10 | + "net/url" |
| 11 | + "strings" |
| 12 | + "time" |
| 13 | +) |
| 14 | + |
| 15 | +// InfisicalProvider resolves secrets from a self-hosted (or cloud) Infisical |
| 16 | +// instance over its HTTP API. Like the Vault provider it is dependency-free |
| 17 | +// (net/http, no SDK) — a Tier 2 backend only needs Resolve. |
| 18 | +// |
| 19 | +// A $secret marker names a secret by (optional) path and name: |
| 20 | +// |
| 21 | +// {$secret: {provider: "infisical", key: "DB_PASSWORD"}} |
| 22 | +// {$secret: {provider: "infisical", key: "/app/prod#DB_PASSWORD"}} |
| 23 | +// |
| 24 | +// The key is "[<secretPath>#]<secretName>" — the path defaults to "/". The |
| 25 | +// project and environment are fixed per configured provider (register multiple |
| 26 | +// named providers for multiple environments), mirroring how the Vault provider |
| 27 | +// takes a namespace. |
| 28 | +// |
| 29 | +// Auth is one of two modes, chosen by whether a clientID is configured: |
| 30 | +// - Universal Auth (machine identity): clientID + secret (the client secret) |
| 31 | +// are exchanged for a short-lived access token via the universal-auth login |
| 32 | +// endpoint, then used as a Bearer token. |
| 33 | +// - Static token: secret is used directly as a Bearer token (a service token |
| 34 | +// or a pre-minted access token). |
| 35 | +type InfisicalProvider struct { |
| 36 | + name string |
| 37 | + host string // base URL, no trailing slash |
| 38 | + clientID string // set → Universal Auth; empty → static bearer token |
| 39 | + secret string // client secret (universal auth) or bearer token (static) |
| 40 | + projectID string |
| 41 | + environment string |
| 42 | + client *http.Client |
| 43 | +} |
| 44 | + |
| 45 | +// NewInfisicalProvider builds an Infisical-backed SecretProvider. When clientID |
| 46 | +// is non-empty, secret is the Universal Auth client secret; otherwise secret is |
| 47 | +// used directly as a Bearer token (service/access token). |
| 48 | +func NewInfisicalProvider(name, host, clientID, secret, projectID, environment string) *InfisicalProvider { |
| 49 | + return &InfisicalProvider{ |
| 50 | + name: name, |
| 51 | + host: strings.TrimRight(host, "/"), |
| 52 | + clientID: clientID, |
| 53 | + secret: secret, |
| 54 | + projectID: projectID, |
| 55 | + environment: environment, |
| 56 | + client: &http.Client{Timeout: 15 * time.Second}, |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +func (p *InfisicalProvider) Name() string { return p.name } |
| 61 | + |
| 62 | +func (p *InfisicalProvider) Resolve(ctx context.Context, key string) (string, error) { |
| 63 | + secretPath, secretName := "/", key |
| 64 | + if path, name, ok := strings.Cut(key, "#"); ok { |
| 65 | + if name == "" { |
| 66 | + return "", fmt.Errorf("infisical key %q: secret name after # is empty", key) |
| 67 | + } |
| 68 | + secretName = name |
| 69 | + if path != "" { |
| 70 | + secretPath = path |
| 71 | + } |
| 72 | + } |
| 73 | + if secretName == "" { |
| 74 | + return "", fmt.Errorf("infisical key %q: secret name is empty", key) |
| 75 | + } |
| 76 | + |
| 77 | + token, err := p.accessToken(ctx) |
| 78 | + if err != nil { |
| 79 | + return "", err |
| 80 | + } |
| 81 | + |
| 82 | + q := url.Values{} |
| 83 | + q.Set("workspaceId", p.projectID) |
| 84 | + q.Set("environment", p.environment) |
| 85 | + q.Set("secretPath", secretPath) |
| 86 | + reqURL := fmt.Sprintf("%s/api/v3/secrets/raw/%s?%s", p.host, url.PathEscape(secretName), q.Encode()) |
| 87 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) |
| 88 | + if err != nil { |
| 89 | + return "", err |
| 90 | + } |
| 91 | + req.Header.Set("Authorization", "Bearer "+token) |
| 92 | + resp, err := p.client.Do(req) |
| 93 | + if err != nil { |
| 94 | + return "", fmt.Errorf("infisical request: %w", err) |
| 95 | + } |
| 96 | + defer func() { _ = resp.Body.Close() }() |
| 97 | + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| 98 | + if resp.StatusCode != http.StatusOK { |
| 99 | + return "", fmt.Errorf("infisical %s (env %s): status %d", secretName, p.environment, resp.StatusCode) |
| 100 | + } |
| 101 | + var parsed struct { |
| 102 | + Secret struct { |
| 103 | + SecretValue string `json:"secretValue"` |
| 104 | + } `json:"secret"` |
| 105 | + } |
| 106 | + if err := json.Unmarshal(body, &parsed); err != nil { |
| 107 | + return "", fmt.Errorf("infisical %s: decode response: %w", secretName, err) |
| 108 | + } |
| 109 | + if parsed.Secret.SecretValue == "" { |
| 110 | + return "", fmt.Errorf("infisical %s: empty or missing secretValue", secretName) |
| 111 | + } |
| 112 | + return parsed.Secret.SecretValue, nil |
| 113 | +} |
| 114 | + |
| 115 | +// accessToken returns the Bearer token to use: the static token as-is when no |
| 116 | +// clientID is configured, or a fresh Universal Auth access token otherwise. |
| 117 | +// (Not cached — resolution happens a handful of times per apply, and a stale |
| 118 | +// cached token would be worse than a re-login.) |
| 119 | +func (p *InfisicalProvider) accessToken(ctx context.Context) (string, error) { |
| 120 | + if p.clientID == "" { |
| 121 | + if p.secret == "" { |
| 122 | + return "", fmt.Errorf("infisical %q: no clientId (Universal Auth) or token configured", p.name) |
| 123 | + } |
| 124 | + return p.secret, nil |
| 125 | + } |
| 126 | + reqBody, _ := json.Marshal(map[string]string{ |
| 127 | + "clientId": p.clientID, |
| 128 | + "clientSecret": p.secret, |
| 129 | + }) |
| 130 | + reqURL := p.host + "/api/v1/auth/universal-auth/login" |
| 131 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(reqBody)) |
| 132 | + if err != nil { |
| 133 | + return "", err |
| 134 | + } |
| 135 | + req.Header.Set("Content-Type", "application/json") |
| 136 | + resp, err := p.client.Do(req) |
| 137 | + if err != nil { |
| 138 | + return "", fmt.Errorf("infisical login: %w", err) |
| 139 | + } |
| 140 | + defer func() { _ = resp.Body.Close() }() |
| 141 | + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| 142 | + if resp.StatusCode != http.StatusOK { |
| 143 | + return "", fmt.Errorf("infisical login: status %d", resp.StatusCode) |
| 144 | + } |
| 145 | + var parsed struct { |
| 146 | + AccessToken string `json:"accessToken"` |
| 147 | + } |
| 148 | + if err := json.Unmarshal(body, &parsed); err != nil { |
| 149 | + return "", fmt.Errorf("infisical login: decode response: %w", err) |
| 150 | + } |
| 151 | + if parsed.AccessToken == "" { |
| 152 | + return "", fmt.Errorf("infisical login: no accessToken in response") |
| 153 | + } |
| 154 | + return parsed.AccessToken, nil |
| 155 | +} |
0 commit comments