Files
heicode-mananger/heicode/controller/secret_store.go
T
efb0016f5c fix(secret): 撤销资源时删除 Key Vault 凭证材料 (#4) (#13)
#4:撤销资源此前只把 DB 状态置 "revoked",真凭证仍留在 Azure Key Vault。
现在 DeleteResource 同时删除 KV 中的 secret(best-effort:DB 撤销已提交,
外部 KV 失败只记日志、不阻塞响应)。

- secret_store.go:新增 deleteSecret / purgeSecret / rotateSecret
  (AKV REST,api-version=7.4,与 putSecret/getJSONSecret 同风格;404 幂等)。
  ※ 该文件受组织权限策略保护、自动化无读写权,本段由 HM owner 手动粘贴,
    审阅者请核对其与本意一致。
- resource.go:DeleteResource 在撤销事务提交后调用 deleteSecret(resource.SecretRef)。

本轮为 #4 最小修复(堵住"撤销后 KV 材料残留"的洞)。后续仍待:
purgeSecret 接入 30/90 天生命周期、轮换流程。

go build ./... 与 go vet 通过。

影响面(组织规则第 10 条):仅 Manager(HM) —— 涉密钥/安全/审计。
不影响 Client / Swarm / AM 契约 / CodeGW / 计费 / 发布链路。

Refs #4

Co-authored-by: chenchen <chenchen@xinghanlab.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:26:47 +08:00

384 lines
12 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})
}
// deleteSecret removes a secret from Azure Key Vault by its azkv:// ref. Used
// when a resource binding is revoked / an account is disabled so the real
// credential material no longer lingers in the vault (issue #4). Idempotent:
// an already-gone secret (HTTP 404) is treated as success.
//
// With soft-delete enabled (the Azure default) this moves the secret to the
// recoverable "deleted" state; call purgeSecret to make it unrecoverable.
func (s secretStoreClient) deleteSecret(secretRef string) error {
name, err := s.secretNameFromRef(secretRef)
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.MethodDelete, endpoint, nil)
if err != nil {
return err
}
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.StatusNotFound {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
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 delete failed: %s", message)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// purgeSecret permanently removes a soft-deleted secret (DELETE
// /deletedsecrets/{name}), making it unrecoverable — for the 30-day
// "delete or render unrecoverable" lifecycle (issue #4). Requires purge
// permission and a vault without purge-protection. 404 (nothing to purge)
// and 403 (purge not permitted; soft-delete already removed it from use)
// are treated as non-fatal.
func (s secretStoreClient) purgeSecret(secretRef string) error {
name, err := s.secretNameFromRef(secretRef)
if err != nil {
return err
}
token, err := s.accessToken()
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/deletedsecrets/%s?api-version=7.4", s.vaultURL, url.PathEscape(name))
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
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.StatusNotFound || resp.StatusCode == http.StatusForbidden {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
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 purge failed: %s", message)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// rotateSecret stores a new value for an existing secret (by its azkv:// ref),
// creating a new Key Vault version while keeping the same name / secret_ref.
// Azure keeps prior versions; getJSONSecret returns the latest. The caller
// obtains the fresh credential out-of-band (e.g. a re-issued PAT) and passes it.
func (s secretStoreClient) rotateSecret(secretRef string, data map[string]any) (string, error) {
name, err := s.secretNameFromRef(secretRef)
if err != nil {
return "", err
}
return s.putSecret(name, data)
}