#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>
92 lines
3.8 KiB
Go
92 lines
3.8 KiB
Go
package controller
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/heicode/manager/model"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// #33: an unscoped (empty-prefix) purge must be refused unless the operator
|
|
// explicitly declares the vault exclusive to HM, so HM never permanently purges
|
|
// another tenant's soft-deleted secrets in a shared vault.
|
|
func TestSecretPurgeScopeAllowed(t *testing.T) {
|
|
allowed, desc := secretPurgeScopeAllowed("", false)
|
|
require.False(t, allowed)
|
|
require.Contains(t, desc, "refusing to purge an entire")
|
|
|
|
allowed, desc = secretPurgeScopeAllowed(" ", true)
|
|
require.True(t, allowed)
|
|
require.Contains(t, desc, "ENTIRE vault")
|
|
|
|
allowed, desc = secretPurgeScopeAllowed("heicode-", false)
|
|
require.True(t, allowed)
|
|
require.Contains(t, desc, "heicode-")
|
|
}
|
|
|
|
func TestParseDeletedSecretsPage(t *testing.T) {
|
|
body := []byte(`{
|
|
"value": [
|
|
{"recoveryId":"https://v.vault.azure.net/deletedsecrets/users-7-bindings-abc","id":"https://v.vault.azure.net/secrets/users-7-bindings-abc","deletedDate":1700000000},
|
|
{"id":"https://v.vault.azure.net/secrets/foo","deletedDate":1700001000}
|
|
],
|
|
"nextLink":"https://v.vault.azure.net/deletedsecrets?api-version=7.4&$skiptoken=xyz"
|
|
}`)
|
|
items, next, err := parseDeletedSecretsPage(body)
|
|
require.NoError(t, err)
|
|
require.Len(t, items, 2)
|
|
require.Equal(t, "users-7-bindings-abc", items[0].name) // from recoveryId
|
|
require.EqualValues(t, 1700000000, items[0].deletedDate)
|
|
require.Equal(t, "foo", items[1].name) // recoveryId absent -> falls back to id
|
|
require.EqualValues(t, 1700001000, items[1].deletedDate)
|
|
require.NotEmpty(t, next)
|
|
}
|
|
|
|
func TestSecretExpired(t *testing.T) {
|
|
const now int64 = 1_000_000_000
|
|
const day int64 = 86400
|
|
require.True(t, secretExpired(now-31*day, 30, now), "31d old past 30d retention")
|
|
require.True(t, secretExpired(now-30*day, 30, now), "exactly 30d is past retention")
|
|
require.False(t, secretExpired(now-29*day, 30, now), "29d old still within retention")
|
|
require.False(t, secretExpired(0, 30, now), "unknown deletedDate never expires")
|
|
require.False(t, secretExpired(now-100*day, 0, now), "retention<=0 disables purge")
|
|
}
|
|
|
|
func TestLastPathSegment(t *testing.T) {
|
|
require.Equal(t, "abc", lastPathSegment("https://v.vault.azure.net/deletedsecrets/abc"))
|
|
require.Equal(t, "x", lastPathSegment("https://v.vault.azure.net/secrets/x/"))
|
|
require.Equal(t, "plain", lastPathSegment("plain"))
|
|
require.Equal(t, "", lastPathSegment(""))
|
|
}
|
|
|
|
// With no Key Vault configured, revokeUserResourceSecrets must still flip the
|
|
// user's secret-bearing bindings to revoked (DB is authoritative) and leave
|
|
// other users / secret-less bindings untouched.
|
|
func TestRevokeUserResourceSecrets_MarksRevoked(t *testing.T) {
|
|
setupResourceControllerTestDB(t)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.ResourceBinding{}))
|
|
t.Setenv("AZURE_KEY_VAULT_URL", "") // force secret store unconfigured -> KV skipped
|
|
|
|
mk := func(uid int, secretRef string) model.ResourceBinding {
|
|
b := model.ResourceBinding{UserId: uid, Name: "n", ResourceType: "git", SecretRef: secretRef, Status: "active"}
|
|
require.NoError(t, model.DB.Create(&b).Error)
|
|
return b
|
|
}
|
|
withSecretA := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-a")
|
|
withSecretB := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-b")
|
|
noSecret := mk(7777, "") // not selected (secret_ref empty) -> stays active
|
|
otherUser := mk(8888, "azkv://v.vault.azure.net/secrets/users-8888-a")
|
|
|
|
revokeUserResourceSecrets(7777)
|
|
|
|
get := func(id int) string {
|
|
var b model.ResourceBinding
|
|
require.NoError(t, model.DB.Where("id = ?", id).First(&b).Error)
|
|
return b.Status
|
|
}
|
|
require.Equal(t, "revoked", get(withSecretA.Id))
|
|
require.Equal(t, "revoked", get(withSecretB.Id))
|
|
require.Equal(t, "active", get(noSecret.Id), "secret-less binding must not be revoked")
|
|
require.Equal(t, "active", get(otherUser.Id), "another user's binding must be untouched")
|
|
}
|