mirror of
https://github.com/luxfi/kms.git
synced 2026-10-30 16:23:36 +00:00
A list is a subtree query — it spans sub-paths, and with no env filter it spans environments. The wire answered with bare NAMES, and a bare name is not an address in a store keyed by (path, env, name): the only thing a caller can do with one is re-join it at the path it happened to query. For any record one segment deeper, or in another environment, that names a key which does not exist. So the list named a secret and the matching get missed it — and LoadEnv, whose whole job is "fetch everything here and set it", exits the process on the first such record. The list now answers with COORDINATES on both framings, and the coordinate type moves to its own leaf package: the client needed to name a record and could not import the server's storage package to do it without dragging the database into every binary that reads one secret. One coordinate, one place, imported by store, zapserver, zapclient and the HTTP face alike. GetSecretsWith reads each record at its own coordinate. It still keys the result by name, because os.Setenv does — an environment is a flat namespace — so two records under the subtree that share a name now ERROR naming both coordinates instead of one silently winning. A process that boots with the wrong DATABASE_URL and never learns is the failure being avoided. Find also bounds itself (10000 rows) and SAYS when it did. An enumeration is authenticated, but one request that pulls the whole keyspace into memory and onto the wire is still an amplification lever — and a cap that did not report itself would reintroduce the very defect this surface was hardened against: a short list that reads as the whole truth. Whitespace in an env or name is refused on write for the same reason: a list query trims its filter, so a stored "prod " could never be selected by ?env=prod and would answer empty, which reads as an empty store. Co-authored-by: Hanzo Dev <[email protected]>
68 lines
2.5 KiB
Go
68 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/luxfi/kms/pkg/secret"
|
|
)
|
|
|
|
// One canonical spelling per path. The POST body carries a free-form path while
|
|
// a GET's path comes from the URL, so the two framings used to disagree over a
|
|
// leading slash: the write landed at kms/secrets//svc/... and every read looked
|
|
// under kms/secrets/svc/... . 201 then 404 — the caller was told the write
|
|
// succeeded and the value was unreachable, so a consumer took an empty
|
|
// credential from a store that had reported success.
|
|
func TestSecretKeyPathIsCanonical(t *testing.T) {
|
|
want := string(secretKey("svc", "TOKEN", "prod"))
|
|
for _, spelling := range []string{"/svc", "svc/", "/svc/"} {
|
|
if got := string(secretKey(spelling, "TOKEN", "prod")); got != want {
|
|
t.Errorf("secretKey(%q) = %q, want %q", spelling, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The property that matters to a caller: whatever spelling wrote the record,
|
|
// every other spelling reads THAT record back — and there is only ever one.
|
|
func TestPutThenGetAcrossPathSpellings(t *testing.T) {
|
|
s := findTestStore(t)
|
|
|
|
if err := s.Put(&Secret{Path: "/svc", Env: "prod", Name: "TOKEN", Ciphertext: []byte("v1")}); err != nil {
|
|
t.Fatalf("put: %v", err)
|
|
}
|
|
for _, spelling := range []string{"svc", "/svc", "svc/", "/svc/"} {
|
|
got, err := s.Get(spelling, "TOKEN", "prod")
|
|
if err != nil {
|
|
t.Fatalf("get(%q): %v", spelling, err)
|
|
}
|
|
if string(got.Ciphertext) != "v1" {
|
|
t.Fatalf("get(%q) returned a different record", spelling)
|
|
}
|
|
}
|
|
|
|
// One record, not four: a write through another spelling must UPDATE, not
|
|
// fork a second stranded copy that only its own spelling can reach.
|
|
if err := s.Put(&Secret{Path: "svc/", Env: "prod", Name: "TOKEN", Ciphertext: []byte("v2")}); err != nil {
|
|
t.Fatalf("re-put: %v", err)
|
|
}
|
|
refs, _, err := s.Find(secret.Query{Path: "svc", Env: "prod"})
|
|
if err != nil {
|
|
t.Fatalf("find: %v", err)
|
|
}
|
|
if len(refs) != 1 {
|
|
t.Fatalf("find = %d records, want 1 (a spelling forked the keyspace): %+v", len(refs), refs)
|
|
}
|
|
got, err := s.Get("/svc", "TOKEN", "prod")
|
|
if err != nil || string(got.Ciphertext) != "v2" {
|
|
t.Fatalf("get after re-put = %v, %v; want v2", got, err)
|
|
}
|
|
|
|
// And a listed coordinate addresses the record it names — a listing whose
|
|
// rows cannot be fetched or deleted is worse than none.
|
|
if _, err := s.Get(refs[0].Path, refs[0].Name, refs[0].Env); err != nil {
|
|
t.Fatalf("listed coordinate %+v is not fetchable: %v", refs[0], err)
|
|
}
|
|
if err := s.Delete(refs[0].Path, refs[0].Name, refs[0].Env); err != nil {
|
|
t.Fatalf("listed coordinate %+v is not deletable: %v", refs[0], err)
|
|
}
|
|
}
|