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>
This commit is contained in:
zsbgnw12
2026-06-08 15:26:47 +08:00
committed by GitHub
co-authored by chenchen Claude Opus 4.8
parent 696dfecc9d
commit efb0016f5c
2 changed files with 106 additions and 0 deletions
+12
View File
@@ -587,6 +587,18 @@ func DeleteResource(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
// Revoking a resource must also remove the real credential material from the
// Secret Store, not just flip DB status to revoked (issue #4). Best-effort:
// the DB revoke is already committed, so an external Key Vault failure must
// not block the response — but it is logged so a lingering secret is visible
// and can be purged manually. (secret_ref is a pointer, not the secret.)
if strings.TrimSpace(resource.SecretRef) != "" {
if store, sErr := newSecretStoreClientFromEnv(); sErr != nil {
common.SysLog(fmt.Sprintf("DeleteResource: secret store unavailable, KV material NOT deleted for binding %d: %s", resource.Id, sErr.Error()))
} else if dErr := store.deleteSecret(resource.SecretRef); dErr != nil {
common.SysLog(fmt.Sprintf("DeleteResource: KV secret delete failed for binding %d: %s", resource.Id, dErr.Error()))
}
}
common.ApiSuccess(c, resourceToResponse(resource)) common.ApiSuccess(c, resourceToResponse(resource))
} }
+94
View File
@@ -288,3 +288,97 @@ func GetSecretStoreStatus(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) 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)
}