4ed938cce4
The recovery unit on the customer's drive now carries the PORTABLE secret class, so Tier-1/Tier-2 restore no longer depends on the whole-guest tier. A customer needs the drive and nothing else. Part 0's rulings overturned the brief's recommendation, on evidence: - the data_key flag is untrustworthy (4+ encryption keys the catalog itself labels as such are unflagged) -> R-127 - a DB password is not resettable in practice: POSTGRES_PASSWORD is ignored once PGDATA is non-empty, so a regenerated value leaves the app unable to authenticate against its own restored rows while the dump replay still reports success (proven on a throwaway postgres:16-alpine) Ruling (operator): type:secret travels, type:password never does, minus the nonPortableSecrets code register. Plaintext -- withholding the internet- reachable class is what licenses that, and the two are coupled. Precedence: the UNIT WINS over the guest -- the unit's secrets were captured in the same run as the dumps beside them, so they match the data being restored. The fail-closed data-key gate is unchanged. Secret values are never logged; the manifest records NAMES only.
77 lines
3.1 KiB
Go
77 lines
3.1 KiB
Go
package stacks
|
|
|
|
import "testing"
|
|
|
|
// TestPortableSecretEnvVars pins the D5 ruling (operator, 2026-07-30) about WHICH secrets may travel
|
|
// on the customer's backup drive: `type: secret` travels, `type: password` does not, minus the
|
|
// nonPortableSecrets register.
|
|
//
|
|
// This is a security boundary, so the test asserts the CONSEQUENCE in both directions — what travels
|
|
// and, more importantly, what must not. A change that widens the class fails here.
|
|
func TestPortableSecretEnvVars(t *testing.T) {
|
|
t.Run("type=secret travels, type=password does not", func(t *testing.T) {
|
|
meta := &Metadata{Slug: "paperless-ngx", DeployFields: []DeployField{
|
|
{EnvVar: "DB_PASSWORD", Type: "secret"},
|
|
{EnvVar: "PAPERLESS_SECRET_KEY", Type: "secret"},
|
|
{EnvVar: "PAPERLESS_ADMIN_PASSWORD", Type: "password"},
|
|
{EnvVar: "SUBDOMAIN", Type: "subdomain"},
|
|
}}
|
|
got := PortableSecretEnvVars(meta)
|
|
want := []string{"DB_PASSWORD", "PAPERLESS_SECRET_KEY"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("portable = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] { // order must be stable metadata order (checksum-skip depends on it)
|
|
t.Errorf("portable[%d] = %q, want %q (got %v)", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an internet-reachable admin login is WITHHELD even when typed secret", func(t *testing.T) {
|
|
meta := &Metadata{Slug: "vaultwarden", DeployFields: []DeployField{
|
|
{EnvVar: "ADMIN_TOKEN", Type: "secret"},
|
|
}}
|
|
if got := PortableSecretEnvVars(meta); len(got) != 0 {
|
|
t.Errorf("vaultwarden/ADMIN_TOKEN must NOT travel (it gates the public /admin panel), got %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("the register is scoped to its app, not the bare env-var name", func(t *testing.T) {
|
|
// Another app's ADMIN_TOKEN is not vaultwarden's and must not be caught by the register.
|
|
meta := &Metadata{Slug: "some-other-app", DeployFields: []DeployField{
|
|
{EnvVar: "ADMIN_TOKEN", Type: "secret"},
|
|
}}
|
|
if got := PortableSecretEnvVars(meta); len(got) != 1 || got[0] != "ADMIN_TOKEN" {
|
|
t.Errorf("register must be slug-scoped, got %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("no deploy fields — nothing travels", func(t *testing.T) {
|
|
if got := PortableSecretEnvVars(&Metadata{Slug: "x"}); len(got) != 0 {
|
|
t.Errorf("want empty, got %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("portable is a SUBSET of SensitiveEnvVars", func(t *testing.T) {
|
|
// The unit's app.yaml splits env into non-secret + portable using two different helpers; if
|
|
// PortableSecretEnvVars ever returned a name SensitiveEnvVars does not, that name would land in
|
|
// NonSecretEnv as well and the disjointness GetStackRecoveryInfo relies on would break.
|
|
meta := &Metadata{Slug: "nextcloud", DeployFields: []DeployField{
|
|
{EnvVar: "DB_PASSWORD", Type: "secret"},
|
|
{EnvVar: "MYSQL_ROOT_PASSWORD", Type: "secret"},
|
|
{EnvVar: "NEXTCLOUD_ADMIN_PASSWORD", Type: "password"},
|
|
{EnvVar: "DOMAIN", Type: "domain"},
|
|
}}
|
|
sensitive := make(map[string]bool)
|
|
for _, n := range SensitiveEnvVars(meta) {
|
|
sensitive[n] = true
|
|
}
|
|
for _, n := range PortableSecretEnvVars(meta) {
|
|
if !sensitive[n] {
|
|
t.Errorf("%q is portable but not sensitive — it would also land in NonSecretEnv", n)
|
|
}
|
|
}
|
|
})
|
|
}
|