Files
kms/env_required_test.go
828da1b916 fix(kms): require explicit env on secret writes (no silent default) (#22)
env is a first-class component of the storage key
(kms/secrets/{path}/{env}/{name}); a POST/PATCH that omitted env used to
silently default to "default", committing the write to a bucket that
project/env/path readers (kms-operator, cluster syncs) never resolve. That
split landed an IAM z-password in env=default while prod kept serving the
stale value. POST/PATCH now fail loud with 400 when env is omitted;
GET/DELETE/LIST keep a compat default for legacy readers (a read/delete
can't plant a value another reader trusts).

Root cause on the client: sdk/go/kmsclient dropped env on every HTTP op
(c.env was used only on the ZAP path), so a client configured for env=prod
still wrote to env=default over HTTP. The HTTP path now sends c.env on the
write body and as ?env= on read/delete, matching the ZAP path. Built
in-tree via a local replace so cmd/kms and cmd/kms-fetch ship the fix; the
two Dockerfiles stage sdk/go/go.mod so `go mod download` resolves the
replaced module.

Regression tests: write without env -> 400; write env=prod is readable via
the operator's project/env/path resolution (sha256 round-trip, values never
printed) and is not visible in env=default.

Claude-Session: https://claude.ai/code/session_01D4FSvT3UfhrFJNQjrctjEj

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-08 07:21:40 -07:00

166 lines
5.6 KiB
Go

// Regression coverage for R-ENV (one-way env). env is a first-class
// component of the storage key (kms/secrets/{path}/{env}/{name}); a
// value-writing mutation that omits env must fail loud (400) instead of
// silently landing in a "default" bucket that project/env/path readers (the
// kms-operator, cluster syncs) never resolve. That split is what let an IAM
// z-password land in env=default while prod kept serving the stale value.
//
// Secret values are never printed — round-trip fidelity is asserted by
// comparing SHA-256 digests.
package kms
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func postSecret(t *testing.T, srvURL, org, tok string, body map[string]string) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", srvURL+"/v1/kms/orgs/"+org+"/secrets", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
func getSecret(t *testing.T, srvURL, org, tok, rest, env string) *http.Response {
t.Helper()
u := srvURL + "/v1/kms/orgs/" + org + "/secrets/" + rest
if env != "" {
u += "?env=" + env
}
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
return resp
}
// A write that omits env must 400 — and must not land anywhere.
func TestEnvRequired_PostWithoutEnv_400(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
resp := postSecret(t, srv.URL, "hanzo", tok, map[string]string{
"path": "iam-passwords", "name": "Z_PASSWORD", "value": "irrelevant",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("omitted-env write: want 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
if msg, _ := m["message"].(string); !strings.Contains(msg, "env is required") {
t.Fatalf("want env-required message, got %q", msg)
}
// Prove the rejected write did not silently populate the default bucket.
g := getSecret(t, srv.URL, "hanzo", tok, "iam-passwords/Z_PASSWORD", "default")
defer g.Body.Close()
if g.StatusCode != http.StatusNotFound {
t.Fatalf("omitted-env write must not land in env=default: GET want 404, got %d", g.StatusCode)
}
}
// A write with an explicit env is readable through the exact
// project(org)/env/path resolution the kms-operator uses
// (GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env={env}) — and is NOT
// visible in any other env bucket.
func TestEnvRequired_PostWithEnvProd_ReadableViaProjectEnvPath(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
const secret = "z-password-98f3-do-not-log"
want := sha256hex(secret)
resp := postSecret(t, srv.URL, "hanzo", tok, map[string]string{
"path": "iam-passwords", "name": "Z_PASSWORD", "env": "prod", "value": secret,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("explicit-env write: want 201, got %d", resp.StatusCode)
}
// Operator resolution: org=hanzo (project), env=prod, path=/iam-passwords.
g := getSecret(t, srv.URL, "hanzo", tok, "iam-passwords/Z_PASSWORD", "prod")
defer g.Body.Close()
if g.StatusCode != http.StatusOK {
t.Fatalf("project/env/path read: want 200, got %d", g.StatusCode)
}
var got struct {
Secret struct {
Value string `json:"value"`
} `json:"secret"`
}
if err := json.NewDecoder(g.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if h := sha256hex(got.Secret.Value); h != want {
t.Fatalf("round-trip value digest mismatch: got %s want %s", h, want)
}
// No cross-bucket bleed: env=default must not resolve the prod write.
gd := getSecret(t, srv.URL, "hanzo", tok, "iam-passwords/Z_PASSWORD", "default")
defer gd.Body.Close()
if gd.StatusCode != http.StatusNotFound {
t.Fatalf("prod write must not be visible in env=default: want 404, got %d", gd.StatusCode)
}
}
// PATCH (update) is a value-writing mutation too: omitting env in both body
// and query must 400 before any CAS check.
func TestEnvRequired_PatchWithoutEnv_400(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
// Seed a prod record so PATCH has a target (env check still precedes CAS).
seed := postSecret(t, srv.URL, "hanzo", tok, map[string]string{
"path": "iam-passwords", "name": "Z_PASSWORD", "env": "prod", "value": "v0",
})
seed.Body.Close()
if seed.StatusCode != http.StatusCreated {
t.Fatalf("seed: want 201, got %d", seed.StatusCode)
}
// PATCH with neither body env nor ?env → 400 (before If-Match/version).
pb, _ := json.Marshal(map[string]any{"value": "rotated", "version": 1})
req, _ := http.NewRequest("PATCH", srv.URL+"/v1/kms/orgs/hanzo/secrets/iam-passwords/Z_PASSWORD", bytes.NewReader(pb))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("patch: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("omitted-env PATCH: want 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
if msg, _ := m["message"].(string); !strings.Contains(msg, "env is required") {
t.Fatalf("want env-required message, got %q", msg)
}
}