Files
heicode-mananger/heicode/controller/secret_store.go
T

291 lines
8.7 KiB
Go

package controller
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
)
type secretStoreClient struct {
vaultURL string
vaultHost string
tokenEndpoint string
clientID string
client *http.Client
}
func newSecretStoreClientFromEnv() (secretStoreClient, error) {
vaultURL := strings.TrimRight(strings.TrimSpace(os.Getenv("AZURE_KEY_VAULT_URL")), "/")
if vaultURL == "" {
return secretStoreClient{}, errors.New("AZURE_KEY_VAULT_URL is not configured")
}
parsed, err := url.Parse(vaultURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return secretStoreClient{}, errors.New("AZURE_KEY_VAULT_URL must be an absolute URL")
}
if parsed.Scheme != "https" && !strings.HasPrefix(parsed.Host, "127.0.0.1") && !strings.HasPrefix(parsed.Host, "localhost") {
return secretStoreClient{}, errors.New("AZURE_KEY_VAULT_URL must use https")
}
tokenEndpoint := strings.TrimSpace(os.Getenv("AZURE_MANAGED_IDENTITY_TOKEN_URL"))
if tokenEndpoint == "" {
tokenEndpoint = "http://169.254.169.254/metadata/identity/oauth2/token"
}
return secretStoreClient{
vaultURL: vaultURL,
vaultHost: parsed.Host,
tokenEndpoint: tokenEndpoint,
clientID: strings.TrimSpace(os.Getenv("AZURE_CLIENT_ID")),
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func (s secretStoreClient) putSecret(name string, data map[string]any) (string, error) {
if len(data) == 0 {
return "", errors.New("secret data required")
}
value, err := common.Marshal(data)
if err != nil {
return "", err
}
body, err := common.Marshal(map[string]any{
"value": string(value),
"contentType": "application/json",
})
if err != nil {
return "", err
}
token, err := s.accessToken()
if err != nil {
return "", err
}
endpoint := fmt.Sprintf("%s/secrets/%s?api-version=7.4", s.vaultURL, url.PathEscape(name))
req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readSecretStoreError(resp.Body)
if message == "" {
message = resp.Status
}
return "", fmt.Errorf("Azure Key Vault secret write failed: %s", message)
}
return s.secretRef(name), nil
}
func (s secretStoreClient) secretRef(name string) string {
return fmt.Sprintf("azkv://%s/secrets/%s", s.vaultHost, name)
}
func (s secretStoreClient) getJSONSecret(secretRef string) (map[string]any, error) {
name, err := s.secretNameFromRef(secretRef)
if err != nil {
return nil, err
}
token, err := s.accessToken()
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("%s/secrets/%s?api-version=7.4", s.vaultURL, url.PathEscape(name))
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readSecretStoreError(resp.Body)
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("Azure Key Vault secret read failed: %s", message)
}
var payload struct {
Value string `json:"value"`
}
if err := common.DecodeJson(resp.Body, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Value) == "" {
return nil, errors.New("Azure Key Vault secret value is empty")
}
var data map[string]any
if err := common.UnmarshalJsonStr(payload.Value, &data); err != nil {
return nil, errors.New("Azure Key Vault secret value must be JSON")
}
if len(data) == 0 {
return nil, errors.New("Azure Key Vault secret value is empty")
}
return data, nil
}
func (s secretStoreClient) secretNameFromRef(secretRef string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(secretRef))
if err != nil || parsed.Scheme != "azkv" || parsed.Host == "" {
return "", errors.New("secret_ref must use azkv://<vault>/secrets/<name>")
}
if !strings.EqualFold(parsed.Host, s.vaultHost) {
return "", errors.New("secret_ref vault does not match configured Azure Key Vault")
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) < 2 || parts[0] != "secrets" || strings.TrimSpace(parts[1]) == "" {
return "", errors.New("secret_ref must use azkv://<vault>/secrets/<name>")
}
return parts[1], nil
}
func (s secretStoreClient) accessToken() (string, error) {
u, err := url.Parse(s.tokenEndpoint)
if err != nil {
return "", fmt.Errorf("invalid Azure managed identity token endpoint: %w", err)
}
q := u.Query()
if q.Get("api-version") == "" {
q.Set("api-version", "2018-02-01")
}
if q.Get("resource") == "" {
q.Set("resource", "https://vault.azure.net")
}
if s.clientID != "" && q.Get("client_id") == "" {
q.Set("client_id", s.clientID)
}
u.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
req.Header.Set("Metadata", "true")
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("Azure managed identity token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readSecretStoreError(resp.Body)
if message == "" {
message = resp.Status
}
return "", fmt.Errorf("Azure managed identity token request failed: %s", message)
}
var payload struct {
AccessToken string `json:"access_token"`
}
if err := common.DecodeJson(resp.Body, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.AccessToken) == "" {
return "", errors.New("Azure managed identity token response missing access_token")
}
return payload.AccessToken, nil
}
func readSecretStoreError(body io.Reader) string {
var payload struct {
Errors []string `json:"errors"`
Error struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error"`
}
if err := common.DecodeJson(body, &payload); err != nil {
return ""
}
if payload.Error.Message != "" {
if payload.Error.Code != "" {
return payload.Error.Code + ": " + payload.Error.Message
}
return payload.Error.Message
}
return strings.Join(payload.Errors, "; ")
}
func (s secretStoreClient) probe() (int, error) {
token, err := s.accessToken()
if err != nil {
return 0, err
}
endpoint := s.vaultURL + "/secrets?api-version=7.4&maxresults=1"
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
// secretStoreStatusResponse is the public-safe envelope rendered by
// the admin status page. It never includes secret names or values.
type secretStoreStatusResponse struct {
Configured bool `json:"configured"` // env vars present?
Reachable bool `json:"reachable"` // Key Vault data plane responded?
Provider string `json:"provider,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
Message string `json:"message,omitempty"` // human-readable status
CheckedAt int64 `json:"checked_at"` // unix ms (server clock)
}
// GetSecretStoreStatus returns a sanitized Azure Key Vault status snapshot.
func GetSecretStoreStatus(c *gin.Context) {
out := secretStoreStatusResponse{
CheckedAt: time.Now().UnixMilli(),
Provider: "azure_key_vault",
AuthMethod: "managed_identity",
}
client, err := newSecretStoreClientFromEnv()
if err != nil {
out.Configured = false
out.Message = err.Error()
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
out.Configured = true
status, err := client.probe()
if err != nil {
out.Reachable = false
out.Message = "Azure Key Vault probe failed: " + err.Error()
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
out.Reachable = true
switch {
case status == http.StatusForbidden:
out.Message = "Azure Key Vault reachable, but managed identity lacks list permission; secret writes can still work if set permission is granted"
case status == http.StatusUnauthorized:
out.Message = "Azure Key Vault reachable, but managed identity was not authorized"
case status >= http.StatusOK && status < http.StatusMultipleChoices:
out.Message = "Azure Key Vault is reachable"
default:
out.Message = fmt.Sprintf("Azure Key Vault returned HTTP %d", status)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}