Files
heicode-mananger/heicode/controller/secret_lifecycle.go
T
chenchenandClaude Opus 4.8 8fe1f5e131 fix(agent,secret): enforce CRYPTO_SECRET for agent deploy (#31) and guard unscoped vault purge (#33)
#31: HeicodeDeployAgent now refuses to deploy unless CRYPTO_SECRET is explicitly
configured, so the per-agent access_token is sealed with a key that survives a
container restart. common.CryptoSecret is never literally "" (defaults to
uuid/SessionSecret), so the sealAgentToken plaintext fallback was effectively
unreachable; the real hazard is an ephemeral random seal key making tokens
undecryptable after restart. Dev-only override: HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV=true.
Verified prod container has CRYPTO_SECRET set (64 chars) -> deploy stays allowed.

#33: StartSecretPurgeTask refuses to start a whole-vault purge when
HEICODE_SECRET_PURGE_NAME_PREFIX is empty unless HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true,
so HM never permanently purges another tenant's soft-deleted secrets in a shared
vault. Logs the resolved purge scope at startup.

Both gates extracted into pure, unit-tested helpers (agentTokenSealKeyConfigured,
secretPurgeScopeAllowed). Affects: Manager only (Agent deploy + Secret lifecycle).
No Client/Swarm/billing/audit schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:33:47 +08:00

288 lines
11 KiB
Go

package controller
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// Secret lifecycle (issue #4). Beyond the revoke-time delete already shipped in
// DeleteResource, this file closes the remaining lifecycle:
// 1. revokeUserResourceSecrets — on account disable/delete, soft-delete ALL of
// the user's resource-bound Key Vault secrets (full cleanup, not just DB).
// 2. StartSecretPurgeTask — daily job that PERMANENTLY purges vault secrets
// soft-deleted ≥ retention (default 30 days): the "30-day delete / render
// unrecoverable" requirement.
// 3. rotation — UpsertResourceSecret re-PUTs a credential, which Azure stores
// as a new version (rotateSecret == putSecret); see resource.go.
// revokeUserResourceSecrets revokes every still-active resource binding owned by
// userID and best-effort soft-deletes its Key Vault material, so disabling or
// deleting an account never leaves live credentials in the vault. The DB revoke
// is authoritative and committed first; a Key Vault outage is logged but never
// blocks account management. Returns the number of secrets soft-deleted.
func revokeUserResourceSecrets(userID int) int {
if userID <= 0 || model.DB == nil {
return 0
}
var bindings []model.ResourceBinding
if err := model.DB.
Where("user_id = ? AND secret_ref <> '' AND status <> ?", userID, "revoked").
Find(&bindings).Error; err != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: list bindings for user %d failed: %s", userID, err.Error()))
return 0
}
if len(bindings) == 0 {
return 0
}
ids := make([]int, 0, len(bindings))
for _, b := range bindings {
ids = append(ids, b.Id)
}
if err := model.DB.Model(&model.ResourceBinding{}).Where("id IN ?", ids).
Update("status", "revoked").Error; err != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: mark revoked for user %d failed: %s", userID, err.Error()))
}
store, sErr := newSecretStoreClientFromEnv()
if sErr != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: secret store unavailable, %d KV secrets NOT deleted for user %d: %s", len(bindings), userID, sErr.Error()))
return 0
}
deleted := 0
for _, b := range bindings {
if dErr := store.deleteSecret(b.SecretRef); dErr != nil {
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: KV delete failed for binding %d (user %d): %s", b.Id, userID, dErr.Error()))
continue
}
deleted++
}
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: user %d — %d bindings revoked, %d KV secrets soft-deleted", userID, len(bindings), deleted))
return deleted
}
// ── 30-day purge ────────────────────────────────────────────────────────────
type deletedSecretInfo struct {
name string
deletedDate int64 // unix seconds, from Azure KV
}
// parseDeletedSecretsPage parses one page of Azure KV GET /deletedsecrets and
// returns the secrets + the nextLink (empty when no more pages). Pure (testable).
func parseDeletedSecretsPage(body []byte) ([]deletedSecretInfo, string, error) {
var page struct {
Value []struct {
RecoveryID string `json:"recoveryId"`
ID string `json:"id"`
DeletedDate int64 `json:"deletedDate"`
} `json:"value"`
NextLink string `json:"nextLink"`
}
if err := common.Unmarshal(body, &page); err != nil {
return nil, "", err
}
items := make([]deletedSecretInfo, 0, len(page.Value))
for _, v := range page.Value {
name := lastPathSegment(v.RecoveryID)
if name == "" {
name = lastPathSegment(v.ID)
}
if name == "" {
continue
}
items = append(items, deletedSecretInfo{name: name, deletedDate: v.DeletedDate})
}
return items, strings.TrimSpace(page.NextLink), nil
}
func lastPathSegment(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Path != "" {
raw = u.Path
}
raw = strings.Trim(raw, "/")
if i := strings.LastIndex(raw, "/"); i >= 0 {
raw = raw[i+1:]
}
return raw
}
// secretExpired reports whether a secret soft-deleted at deletedDate (unix sec)
// is at or past retentionDays old relative to nowUnix. Pure (testable).
func secretExpired(deletedDate int64, retentionDays int, nowUnix int64) bool {
if deletedDate <= 0 || retentionDays <= 0 {
return false
}
return nowUnix-deletedDate >= int64(retentionDays)*86400
}
// listDeletedSecrets returns every soft-deleted secret in the vault (paginated).
func (s secretStoreClient) listDeletedSecrets() ([]deletedSecretInfo, error) {
token, err := s.accessToken()
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("%s/deletedsecrets?api-version=7.4", s.vaultURL)
var out []deletedSecretInfo
for endpoint != "" {
req, rErr := http.NewRequest(http.MethodGet, endpoint, nil)
if rErr != nil {
return nil, rErr
}
req.Header.Set("Authorization", "Bearer "+token)
resp, dErr := s.client.Do(req)
if dErr != nil {
return nil, dErr
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readSecretStoreError(bytes.NewReader(body))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("Azure Key Vault list deleted secrets failed: %s", message)
}
items, next, pErr := parseDeletedSecretsPage(body)
if pErr != nil {
return nil, pErr
}
out = append(out, items...)
endpoint = next
}
return out, nil
}
// purgeExpiredVaultSecrets permanently purges every soft-deleted vault secret
// older than retentionDays. An optional namePrefix restricts purging to secrets
// HM manages (empty = all soft-deleted secrets in the vault). Returns (purged,
// scanned). Purge-protected vaults safely no-op (purgeSecret treats 403 as ok).
func purgeExpiredVaultSecrets(retentionDays int, namePrefix string, nowUnix int64) (purged int, scanned int, err error) {
store, err := newSecretStoreClientFromEnv()
if err != nil {
return 0, 0, err
}
items, err := store.listDeletedSecrets()
if err != nil {
return 0, 0, err
}
prefix := strings.TrimSpace(namePrefix)
for _, it := range items {
if prefix != "" && !strings.HasPrefix(it.name, prefix) {
continue
}
scanned++
if !secretExpired(it.deletedDate, retentionDays, nowUnix) {
continue
}
if pErr := store.purgeSecret(store.secretRef(it.name)); pErr != nil {
common.SysLog(fmt.Sprintf("purgeExpiredVaultSecrets: purge %q failed: %s", it.name, pErr.Error()))
continue
}
common.SysLog(fmt.Sprintf("purgeExpiredVaultSecrets: purged %q (soft-deleted %d days ago)", it.name, (nowUnix-it.deletedDate)/86400))
purged++
}
return purged, scanned, nil
}
var secretPurgeTaskOnce sync.Once
// StartSecretPurgeTask launches the daily background purge of vault secrets that
// have been soft-deleted ≥ HEICODE_SECRET_PURGE_RETENTION_DAYS (default 30) ago,
// satisfying issue #4's "render unrecoverable within 30 days" requirement.
// Disabled by HEICODE_SECRET_PURGE_ENABLED=false; no-ops when no vault is
// configured. HEICODE_SECRET_PURGE_NAME_PREFIX scopes purging to HM-managed
// secrets when the vault is shared.
func StartSecretPurgeTask() {
secretPurgeTaskOnce.Do(func() {
if !common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_ENABLED", true) {
common.SysLog("secret purge task disabled (HEICODE_SECRET_PURGE_ENABLED=false)")
return
}
if strings.TrimSpace(os.Getenv("AZURE_KEY_VAULT_URL")) == "" {
return // no vault configured — nothing to purge
}
// #33: a destructive purge that scans the WHOLE vault is only safe when
// HM owns the vault exclusively. If no name prefix scopes purging to
// HM-managed secrets, require an explicit opt-in (HEICODE_SECRET_PURGE_
// VAULT_EXCLUSIVE=true) so HM never permanently purges another tenant's
// soft-deleted secrets that happen to live in a shared vault.
purgePrefix := strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", ""))
vaultExclusive := common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE", false)
allowed, scopeDesc := secretPurgeScopeAllowed(purgePrefix, vaultExclusive)
if !allowed {
common.SysLog("secret purge task NOT started: " + scopeDesc)
return
}
common.SysLog("secret purge scope: " + scopeDesc)
intervalHours := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_INTERVAL_HOURS", 24)
if intervalHours < 1 {
intervalHours = 24
}
go func() {
time.Sleep(5 * time.Minute) // avoid startup churn
runSecretPurgeOnce()
ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour)
defer ticker.Stop()
for range ticker.C {
runSecretPurgeOnce()
}
}()
common.SysLog(fmt.Sprintf("secret purge task started: retention=%dd interval=%dh", secretPurgeRetentionDays(), intervalHours))
})
}
// secretPurgeScopeAllowed decides whether the destructive vault purge may run,
// given the configured name prefix and the vault-exclusive opt-in (#33). A purge
// that scans the WHOLE vault (empty prefix) is only safe when HM owns the vault
// exclusively, so it must be explicitly opted in. Returns the decision plus a
// human-readable scope/refusal description for the startup log. Pure helper for
// unit testing without touching process env.
func secretPurgeScopeAllowed(namePrefix string, vaultExclusive bool) (bool, string) {
prefix := strings.TrimSpace(namePrefix)
if prefix == "" {
if !vaultExclusive {
return false, "HEICODE_SECRET_PURGE_NAME_PREFIX is empty and HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE!=true — refusing to purge an entire (possibly shared) vault. Set a name prefix to scope to HM-managed secrets, or set HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true only if this vault is exclusive to HM."
}
return true, "ENTIRE vault (HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true, no name prefix)"
}
return true, "secrets with name prefix '" + prefix + "'"
}
func secretPurgeRetentionDays() int {
d := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_RETENTION_DAYS", 30)
if d < 1 {
d = 30
}
return d
}
func runSecretPurgeOnce() {
defer func() {
if r := recover(); r != nil {
common.SysLog(fmt.Sprintf("secret purge task panic recovered: %v", r))
}
}()
prefix := common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", "")
purged, scanned, err := purgeExpiredVaultSecrets(secretPurgeRetentionDays(), prefix, time.Now().Unix())
if err != nil {
common.SysLog("secret purge task: " + err.Error())
return
}
if purged > 0 {
common.SysLog(fmt.Sprintf("secret purge task: purged %d of %d scanned deleted secrets past %d-day retention", purged, scanned, secretPurgeRetentionDays()))
}
}