#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)
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -288,3 +288,97 @@ func GetSecretStoreStatus(c *gin.Context) {
|
||||
}
|
||||
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