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:///secrets/") } 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:///secrets/") } 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}) }