Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe217d518 |
@@ -18,6 +18,10 @@ ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
# kmsclient is an in-repo module built via a local replace (see go.mod).
|
||||
# Stage its go.mod/go.sum so `go mod download` can read the replaced module's
|
||||
# graph before the full source tree is copied.
|
||||
COPY sdk/go/go.mod sdk/go/go.sum ./sdk/go/
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
GOPRIVATE="github.com/luxfi/*,github.com/hanzoai/*" \
|
||||
GONOSUMCHECK="github.com/luxfi/*,github.com/hanzoai/*" \
|
||||
|
||||
@@ -28,6 +28,10 @@ ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
# kmsclient is an in-repo module built via a local replace (see go.mod).
|
||||
# Stage its go.mod/go.sum so `go mod download` can read the replaced module's
|
||||
# graph before the full source tree is copied.
|
||||
COPY sdk/go/go.mod sdk/go/go.sum ./sdk/go/
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
GOPRIVATE="github.com/luxfi/*,github.com/hanzoai/*" \
|
||||
GONOSUMCHECK="github.com/luxfi/*,github.com/hanzoai/*" \
|
||||
|
||||
@@ -94,6 +94,17 @@ IAM use `/v1/iam/*` — not a route this service exposes, and never
|
||||
Mismatch → 409 with the current version. Replay defence is structural,
|
||||
not advisory.
|
||||
- DELETE wipes the version record so a recreate restarts from 1.
|
||||
- **R-ENV (one-way env):** `env` is a first-class component of the storage
|
||||
key (`kms/secrets/{path}/{env}/{name}`) — it can never be aliased. The
|
||||
value-writing mutations **POST and PATCH require an explicit `env`** (in
|
||||
the request body); omitting it is a fail-loud `400`, never a silent
|
||||
`default`. A silent default is exactly what let an IAM z-password land in
|
||||
`env=default` while the operator's `project/env/path` read (env=prod) kept
|
||||
serving the stale value. GET/DELETE/LIST keep a backward-compatible
|
||||
`default` for legacy readers (a read/delete can't plant a value another
|
||||
reader trusts). The canonical `sdk/go/kmsclient` now always sends `env`
|
||||
explicitly on every HTTP op, so new callers are one-way by construction;
|
||||
the server default is a legacy-compat shim, not the intended path.
|
||||
|
||||
### Admin-only
|
||||
|
||||
|
||||
@@ -512,6 +512,27 @@ func registerAuth(mux *http.ServeMux, iamEndpoint string) {
|
||||
})
|
||||
}
|
||||
|
||||
// envRequired enforces that a value-writing secret mutation names its
|
||||
// environment explicitly. env is a first-class component of the storage
|
||||
// key (kms/secrets/{path}/{env}/{name}), so it can never be aliased: a
|
||||
// silent "default" commits the write to one bucket while a project/env/path
|
||||
// reader (the kms-operator, cluster syncs) resolves a different record. That
|
||||
// split is exactly what let an IAM z-password land in env=default while prod
|
||||
// kept serving the stale value. Writes fail loud; reads keep a
|
||||
// backward-compatible default (a read cannot plant a value another reader
|
||||
// later trusts, and legacy readers that omit env must keep working). Returns
|
||||
// false — after writing the 400 — when env is empty; the caller records the
|
||||
// audit row and returns.
|
||||
func envRequired(w http.ResponseWriter, env string) bool {
|
||||
if strings.TrimSpace(env) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"message": `env is required — set "env" in the request body; there is no default. A silent default would split this write from the project/env/path record readers resolve.`,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// registerSecretRoutes mounts the canonical lux/kms HTTP secret CRUD at
|
||||
// /v1/kms/orgs/{org}/secrets/... — one path, one way.
|
||||
//
|
||||
@@ -521,6 +542,10 @@ func registerAuth(mux *http.ServeMux, iamEndpoint string) {
|
||||
//
|
||||
// R-12 (audit trail): every request emits one audit row with composite
|
||||
// actor_id "iss:sub". See audit.go for details.
|
||||
//
|
||||
// R-ENV (one-way env): value-writing mutations (POST, PATCH) require an
|
||||
// explicit env via envRequired — no silent "default". GET/DELETE/LIST keep a
|
||||
// compat default (see envRequired) for legacy readers.
|
||||
func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *badger.DB) {
|
||||
get := func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := authorize(w, r)
|
||||
@@ -542,6 +567,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
}
|
||||
env := r.URL.Query().Get("env")
|
||||
if env == "" {
|
||||
// Read keeps a compat default (see envRequired): a read cannot
|
||||
// plant a value another reader trusts, and legacy readers that
|
||||
// omit env must keep working. Only writes fail loud.
|
||||
env = "default"
|
||||
}
|
||||
sec, err := secStore.Get(path, name, env)
|
||||
@@ -587,8 +615,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusBadRequest, 0)
|
||||
return
|
||||
}
|
||||
if req.Env == "" {
|
||||
req.Env = "default"
|
||||
if !envRequired(w, req.Env) {
|
||||
recordAudit(claims, r, req.Path, req.Name, "", http.StatusBadRequest, 0)
|
||||
return
|
||||
}
|
||||
sec := &store.Secret{
|
||||
Name: req.Name,
|
||||
@@ -646,8 +675,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
if env == "" {
|
||||
env = r.URL.Query().Get("env")
|
||||
}
|
||||
if env == "" {
|
||||
env = "default"
|
||||
if !envRequired(w, env) {
|
||||
recordAudit(claims, r, path, name, "", http.StatusBadRequest, 0)
|
||||
return
|
||||
}
|
||||
// Version CAS: require EITHER If-Match header OR body.version. If
|
||||
// both are present they must agree. Missing both → 428 Precondition
|
||||
@@ -740,6 +770,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
|
||||
}
|
||||
env := r.URL.Query().Get("env")
|
||||
if env == "" {
|
||||
// Compat default (see envRequired): DELETE without env removes the
|
||||
// default-env record only — it 404s if that env holds nothing, so
|
||||
// it cannot silently touch another env's value.
|
||||
env = "default"
|
||||
}
|
||||
if err := secStore.Delete(path, name, env); err != nil {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -131,3 +131,11 @@ require (
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
// The kmsclient library lives in this repo (sdk/go) and is released together
|
||||
// with the server. Build the in-tree copy so cmd/kms and cmd/kms-fetch pick up
|
||||
// the fix that sends env explicitly on the HTTP path (see sdk/go/kmsclient).
|
||||
// The require above stays at the last published tag so dropping this replace
|
||||
// falls back to a real version, never a missing one; publish sdk/go/v1.1.1 and
|
||||
// bump the require when cutting the next SDK release.
|
||||
replace github.com/hanzoai/kms/sdk/go => ./sdk/go
|
||||
|
||||
@@ -301,6 +301,14 @@ func (c *Client) secretPath(path, name string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// secretURL is secretPath plus the ?env= query the server keys the record
|
||||
// on. c.env defaults to "default" (see New) and is sent explicitly so reads
|
||||
// and deletes target the configured env instead of relying on the server's
|
||||
// legacy default — the same one-way discipline the write path enforces.
|
||||
func (c *Client) secretURL(path, name string) string {
|
||||
return c.secretPath(path, name) + "?env=" + url.QueryEscape(c.env)
|
||||
}
|
||||
|
||||
// Get fetches a single secret value by path and name.
|
||||
//
|
||||
// On the HTTP path: GET /v1/kms/orgs/{org}/secrets/{path}/{name}.
|
||||
@@ -317,7 +325,7 @@ func (c *Client) httpGet(ctx context.Context, path, name string) (string, error)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kmsclient: auth: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.secretPath(path, name), nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.secretURL(path, name), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kmsclient: build request: %w", err)
|
||||
}
|
||||
@@ -460,9 +468,16 @@ func (c *Client) httpPut(ctx context.Context, path, name, value string) error {
|
||||
c.endpoint,
|
||||
url.PathEscape(c.org),
|
||||
)
|
||||
// env is REQUIRED by the server on writes (no silent "default"): send
|
||||
// c.env explicitly so the write lands in the same env-keyed record that
|
||||
// project/env/path readers resolve. Historically this body omitted env
|
||||
// and the server defaulted it to "default" — silently diverting a client
|
||||
// configured for env=prod into the wrong bucket. That is the bug this
|
||||
// fixes end to end.
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"path": path,
|
||||
"name": name,
|
||||
"env": c.env,
|
||||
"value": value,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(payload)))
|
||||
@@ -499,7 +514,7 @@ func (c *Client) httpDelete(ctx context.Context, path, name string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("kmsclient: auth: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.secretPath(path, name), nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.secretURL(path, name), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kmsclient: build request: %w", err)
|
||||
}
|
||||
|
||||
@@ -223,6 +223,69 @@ func TestPut_SendsCanonicalPayload(t *testing.T) {
|
||||
if gotBody["path"] != "providers/square/dev" || gotBody["name"] != "access_token" || gotBody["value"] != "sq_abc" {
|
||||
t.Errorf("body = %+v, want path=providers/square/dev name=access_token value=sq_abc", gotBody)
|
||||
}
|
||||
// env MUST be on the write body — the server now rejects omitted env. With
|
||||
// no Config.Env set it defaults to "default" and is sent explicitly.
|
||||
if gotBody["env"] != "default" {
|
||||
t.Errorf("body env = %q, want default (sent explicitly, never omitted)", gotBody["env"])
|
||||
}
|
||||
}
|
||||
|
||||
// A client configured for a specific env sends it on the write body, so the
|
||||
// write lands in that env's record rather than the server's legacy default.
|
||||
func TestPut_SendsConfiguredEnv(t *testing.T) {
|
||||
iam := mockIAM(t, "tok")
|
||||
defer iam.Close()
|
||||
|
||||
var gotBody map[string]string
|
||||
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
}))
|
||||
defer kms.Close()
|
||||
|
||||
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo", Env: "prod"})
|
||||
if err := c.Put(context.Background(), "iam-passwords", "Z_PASSWORD", "s3cr3t"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if gotBody["env"] != "prod" {
|
||||
t.Errorf("body env = %q, want prod", gotBody["env"])
|
||||
}
|
||||
}
|
||||
|
||||
// Reads and deletes carry ?env= too, so they target the configured env
|
||||
// instead of relying on any server default.
|
||||
func TestGetDelete_SendEnvQuery(t *testing.T) {
|
||||
iam := mockIAM(t, "tok")
|
||||
defer iam.Close()
|
||||
|
||||
var gotQuery, gotMethod string
|
||||
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
gotMethod = r.Method
|
||||
if r.Method == http.MethodGet {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]any{"value": "v"}})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer kms.Close()
|
||||
|
||||
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo", Env: "prod"})
|
||||
|
||||
if _, err := c.Get(context.Background(), "iam-passwords", "Z_PASSWORD"); err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodGet || gotQuery != "env=prod" {
|
||||
t.Errorf("GET query = %q (method %s), want env=prod", gotQuery, gotMethod)
|
||||
}
|
||||
|
||||
if err := c.Delete(context.Background(), "iam-passwords", "Z_PASSWORD"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodDelete || gotQuery != "env=prod" {
|
||||
t.Errorf("DELETE query = %q (method %s), want env=prod", gotQuery, gotMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_UsesCanonicalPath(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user