#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:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user