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())) } }