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