From efb0016f5cf716423019681d9d6bfbdd6be1be9f Mon Sep 17 00:00:00 2001 From: zsbgnw12 <103713022+zsbgnw12@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:26:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(secret):=20=E6=92=A4=E9=94=80=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=97=B6=E5=88=A0=E9=99=A4=20Key=20Vault=20=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E6=9D=90=E6=96=99=20(#4)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 Co-authored-by: Claude Opus 4.8 --- heicode/controller/resource.go | 12 ++++ heicode/controller/secret_store.go | 94 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index 310010d6..ec2ca023 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -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)) } diff --git a/heicode/controller/secret_store.go b/heicode/controller/secret_store.go index d479ce3f..b9d71db1 100644 --- a/heicode/controller/secret_store.go +++ b/heicode/controller/secret_store.go @@ -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) +} \ No newline at end of file