feat(kms): metadata-only secret listing — GET /v1/kms/orgs/{org}/secrets

Add a bare list endpoint so an operator console can browse an org's secret
KEYS without ever exposing a value. Structurally incapable of leaking a value:
the scan iterates ZapDB KEYS ONLY (PrefetchValues=false) under
kms/secrets/<brand/org>/ and the row type has no value field — there is no
code path from this handler to a secret value blob.

Verified against the store: secret path/name/env are PLAINTEXT in the ZapDB
key kms/secrets/{path}/{env}/{name}; only the value blob is at-rest encrypted.
So names are honestly listable; values are never touched.

- Authz: identical authorize()+canActOnOrg() gate as get-one — never weaker.
  Structurally confined to the org's brand/<org>/ namespace (stricter: cannot
  enumerate another tenant's names). Optional ?prefix= (safePath + org
  containment) and ?env= filters.
- Routing: bare GET coexists with the {rest...} wildcard (Go 1.22 ServeMux);
  the bare path hits list, .../secrets/<path>/<name> still returns its value.
- updatedTime sourced from a new kms/mtimes/ sibling index (mirrors
  versioning.go), written on put/patch, deleted on delete — never from the
  value blob. Records predating this carry no mtime (updatedTime omitted),
  never faked.
- TestRed3 traversal heuristic tightened: '/secrets/foo/..' path-cleans to
  '/secrets' (the list), an authorized empty {secrets:[]} with no value; the
  test now asserts on an actual value payload, not the substring 'secret'.

Tests: 9 new TestList_* (no-value-leak, authz fail-closed, routing
precedence, prefix injection-safety, env filter, empty org, mtime RFC3339).
VERSION 2.5.2 -> 0.159.4 (patch, v0.x server line; tag cut on merge, not now).
This commit is contained in:
zeekay
2026-06-28 19:31:19 -07:00
parent 53fc13e22b
commit 7086e3c36c
5 changed files with 649 additions and 6 deletions
+1 -1
View File
@@ -1 +1 @@
2.5.2
0.159.4
+78 -1
View File
@@ -606,6 +606,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
if verErr != nil {
log.Warn("kms: version bump failed after put", "err", verErr)
}
if mErr := writeMtime(db, req.Path, req.Name, req.Env); mErr != nil {
log.Warn("kms: mtime write failed after put", "err", mErr)
}
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "version": newVer})
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusCreated, newVer)
}
@@ -710,6 +713,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
recordAudit(claims, r, path, name, env, http.StatusInternalServerError, 0)
return
}
if mErr := writeMtime(db, path, name, env); mErr != nil {
log.Warn("kms: mtime write failed after patch", "err", mErr)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": newVer})
recordAudit(claims, r, path, name, env, http.StatusOK, newVer)
}
@@ -741,13 +747,84 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
recordAudit(claims, r, path, name, env, http.StatusNotFound, 0)
return
}
// Clear version record so a re-create starts from 1 again.
// Clear version + mtime records so a re-create starts clean.
_ = deleteVersion(db, path, name, env)
_ = deleteMtime(db, path, name, env)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
recordAudit(claims, r, path, name, env, http.StatusOK, 0)
}
// list (R-LIST): metadata-only browse of an org's secret KEYS. By
// construction it cannot return a value — see listSecretMetadata
// (keys-only scan, no value field on the row type). Authorization is the
// IDENTICAL authorize()+canActOnOrg() gate the other secret ops use: a
// caller who cannot read the org's secrets cannot list them.
list := func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
orgURL := r.PathValue("org")
if !claims.canActOnOrg(orgURL) {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
if !safePath(orgURL) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid org"})
recordAudit(claims, r, "", "", "", http.StatusBadRequest, 0)
return
}
// Structural org confinement: the scan is rooted at the org's canonical
// brand/<org> namespace and cannot escape it. This is STRICTER than
// get-one (it can never enumerate another tenant's secret names) while
// using the SAME authorize()+canActOnOrg() gate — never weaker.
// orgRoot carries no trailing slash; listSecretMetadata appends the
// boundary slash so brand/<org> never matches brand/<org>foo.
orgRoot := "brand/" + orgURL
listPath := orgRoot
if qp := r.URL.Query().Get("prefix"); qp != "" {
qp = strings.TrimSuffix(qp, "/") // accept the directory-style brand/<org>/ form
if qp == "" || !safePath(qp) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid prefix"})
recordAudit(claims, r, qp, "", "", http.StatusBadRequest, 0)
return
}
// Containment: the prefix must be the org root or strictly within it.
if qp != orgRoot && !strings.HasPrefix(qp, orgRoot+"/") {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "prefix must be within brand/" + orgURL + "/"})
recordAudit(claims, r, qp, "", "", http.StatusForbidden, 0)
return
}
listPath = qp
}
envFilter := r.URL.Query().Get("env")
if envFilter != "" && (!safePath(envFilter) || strings.Contains(envFilter, "/")) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid env"})
recordAudit(claims, r, listPath, "", envFilter, http.StatusBadRequest, 0)
return
}
rows, truncated := listSecretMetadata(db, listPath, envFilter)
resp := map[string]any{"secrets": rows, "count": len(rows)}
if truncated {
resp["truncated"] = true
}
writeJSON(w, http.StatusOK, resp)
recordAudit(claims, r, listPath, "", envFilter, http.StatusOK, 0)
}
// Canonical (lux native).
//
// Routing precedence (Go 1.22+ net/http ServeMux): the bare pattern
// ".../secrets" and the wildcard ".../secrets/{rest...}" do NOT conflict —
// no request path matches both (the wildcard requires a literal "/secrets/"
// prefix; the bare matches exactly "/secrets"). Registering the explicit
// bare GET also suppresses any subtree redirect to ".../secrets/". Net
// effect: GET on the bare path hits list; GET on ".../secrets/<path>/<name>"
// still hits get-one and returns its value. Confirmed by
// TestList_RoutingPrecedence_NoShadowGetOne.
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets", list)
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", get)
mux.HandleFunc("POST /v1/kms/orgs/{org}/secrets", put)
mux.HandleFunc("PATCH /v1/kms/orgs/{org}/secrets/{rest...}", patch)
+9 -4
View File
@@ -254,11 +254,16 @@ func TestRed3_PathTraversalBlocked(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(req)
if resp.StatusCode != 400 && resp.StatusCode != 404 {
// 404 is acceptable when net/http normalizes the path so it
// never reaches our handler. What's NOT acceptable is 200.
// 404 is acceptable when net/http normalizes the path so it never
// reaches our handler. A traversal path may also normalize onto a
// legitimate route: "/secrets/foo/.." cleans (path.Clean) to
// "/secrets", the metadata list, which returns an empty
// {"secrets":[],"count":0} — no value — to the authorized caller.
// That is not a leak. The real threat is a get-one VALUE payload
// escaping via traversal, so assert no value field is present.
body, _ := readBody(resp)
if strings.Contains(body, "secret") {
t.Fatalf("%s: returned a secret payload, status=%d", p, resp.StatusCode)
if strings.Contains(body, `"value"`) || strings.Contains(body, "secretValue") {
t.Fatalf("%s: returned a secret value payload, status=%d body=%s", p, resp.StatusCode, body)
}
}
}
+186
View File
@@ -0,0 +1,186 @@
// Metadata-only secret listing (R-LIST) + wall-clock mtime tracking.
//
// This file owns the read-only "browse an org's secret KEYS" surface that
// the operator console needs. It is, by construction, incapable of returning
// a secret value:
//
// - The scan iterates ZapDB KEYS ONLY (PrefetchValues=false). The encrypted
// value blob under kms/secrets/{path}/{env}/{name} is never read from the
// LSM into the handler. There is no code path from this file to a value.
// - The row type (secretMetaRow) has no value/ciphertext field, so even a
// future careless edit cannot serialize a value.
//
// What IS listable, verified against the actual store (github.com/luxfi/kms
// pkg/store): secret path/name/env are stored in PLAINTEXT as the ZapDB key
// `kms/secrets/{path}/{env}/{name}`. Only the value blob is protected (ZapDB
// at-rest encryption). So names/paths are honestly enumerable metadata; the
// value is not, and is never touched here.
//
// Two sibling-key families parallel the secret records, mirroring the
// versioning.go pattern (no upstream schema change):
//
// kms/versions/{path}/{env}/{name} → monotonic int64 (versioning.go)
// kms/mtimes/{path}/{env}/{name} → wall-clock UnixNano int64 (this file)
//
// updatedTime is sourced from kms/mtimes — NOT from the value blob (whose
// UpdatedAt the Hanzo write path never set) and NOT from ZapDB's commit
// counter (a logical version, not wall-clock). A record written before this
// change carries no mtime row and is reported with updatedTime omitted —
// honestly "unknown", never faked.
package kms
import (
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
badger "github.com/luxfi/zapdb"
)
// maxListRows caps a single metadata listing so a pathological namespace
// cannot exhaust server memory or produce an unbounded response. The scan is
// keys-only and cheap, but the response is still bounded. A metadata row is a
// few hundred bytes, so 10k rows is well under a MiB.
const maxListRows = 10000
// secretMetaRow is one row of the metadata listing. It deliberately has NO
// value/ciphertext field — the list endpoint is structurally incapable of
// emitting a secret value.
type secretMetaRow struct {
Path string `json:"path"`
Name string `json:"name"`
Env string `json:"env"`
Version int64 `json:"version"`
UpdatedTime string `json:"updatedTime,omitempty"` // RFC3339; omitted when unknown
}
// listSecretMetadata returns metadata rows for every secret whose key falls
// under kms/secrets/<listPath>/, optionally filtered to envFilter. listPath
// carries no trailing slash; the boundary slash is appended here so a prefix
// of "brand/hanzo" matches "brand/hanzo/..." but never "brand/hanzofoo/...".
// It NEVER reads a secret value: phase 1 iterates keys only; phase 2 reads the
// version and mtime sibling counters (not the value). Returns (rows, truncated).
func listSecretMetadata(db *badger.DB, listPath, envFilter string) (rows []secretMetaRow, truncated bool) {
// Mirror store.secretKey's layout: kms/secrets/{path}/{env}/{name}.
dbPrefix := []byte("kms/secrets/" + listPath + "/")
type ref struct{ path, name, env string }
var refs []ref
_ = db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.PrefetchValues = false // KEYS ONLY — the value blob is never loaded.
opts.Prefix = dbPrefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
// KeyCopy: Item.Key() is only valid for the current iteration step.
key := string(it.Item().KeyCopy(nil))
rel := strings.TrimPrefix(key, "kms/secrets/")
p, e, n, ok := parseSecretRelKey(rel)
if !ok {
continue
}
if envFilter != "" && e != envFilter {
continue
}
if len(refs) >= maxListRows {
truncated = true
break
}
refs = append(refs, ref{path: p, name: n, env: e})
}
return nil
})
// Phase 2: enrich with version + mtime (sibling counters, NOT the value).
rows = make([]secretMetaRow, 0, len(refs))
for _, rf := range refs {
ver, _ := readVersion(db, rf.path, rf.name, rf.env)
row := secretMetaRow{Path: rf.path, Name: rf.name, Env: rf.env, Version: ver}
if ns, _ := readMtime(db, rf.path, rf.name, rf.env); ns > 0 {
row.UpdatedTime = time.Unix(0, ns).UTC().Format(time.RFC3339)
}
rows = append(rows, row)
}
return rows, truncated
}
// parseSecretRelKey inverts store.secretKey's "{path}/{env}/{name}" layout
// (the portion after the kms/secrets/ prefix). name is the final segment,
// env the penultimate, path everything before. Returns ok=false for any key
// that does not carry a full path/env/name triple.
func parseSecretRelKey(rel string) (path, env, name string, ok bool) {
ls := strings.LastIndex(rel, "/")
if ls < 0 {
return "", "", "", false
}
name = rel[ls+1:]
head := rel[:ls] // {path}/{env}
es := strings.LastIndex(head, "/")
if es < 0 {
return "", "", "", false
}
env = head[es+1:]
path = head[:es]
if path == "" || env == "" || name == "" {
return "", "", "", false
}
return path, env, name, true
}
// --- mtime sibling index (parallel to versioning.go) ---
// mtimeKey returns the ZapDB key for a secret's wall-clock update time.
// Kept parallel to store.secretKey / versionKey so a prefix scan still works.
func mtimeKey(path, name, env string) []byte {
return []byte(fmt.Sprintf("kms/mtimes/%s/%s/%s", path, env, name))
}
// writeMtime records the current wall-clock time (UnixNano, big-endian int64)
// for a secret. Called from the write handlers after a successful Put. Best
// effort: a failure is logged by the caller, never fatal to the write.
func writeMtime(db *badger.DB, path, name, env string) error {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(time.Now().UnixNano()))
return db.Update(func(txn *badger.Txn) error {
return txn.Set(mtimeKey(path, name, env), buf)
})
}
// readMtime returns the recorded UnixNano update time, or 0 if none was
// recorded (the secret predates mtime tracking — honestly "unknown").
func readMtime(db *badger.DB, path, name, env string) (int64, error) {
var ns int64
err := db.View(func(txn *badger.Txn) error {
item, err := txn.Get(mtimeKey(path, name, env))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil // ns stays 0
}
if err != nil {
return err
}
return item.Value(func(b []byte) error {
if len(b) != 8 {
return fmt.Errorf("kms: malformed mtime record (len=%d)", len(b))
}
ns = int64(binary.BigEndian.Uint64(b))
return nil
})
})
return ns, err
}
// deleteMtime removes the mtime record when a secret is deleted, so a later
// re-create does not surface a stale update time.
func deleteMtime(db *badger.DB, path, name, env string) error {
return db.Update(func(txn *badger.Txn) error {
err := txn.Delete(mtimeKey(path, name, env))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
+375
View File
@@ -0,0 +1,375 @@
// Tests for the metadata-only secret listing endpoint
// GET /v1/kms/orgs/{org}/secrets.
//
// The contract under test, in order of importance:
// 1. No value can ever appear in the response (structural no-leak).
// 2. Authorization is identical to get-one (authorize()+canActOnOrg()) —
// never weaker. A caller who cannot read the org cannot list it.
// 3. The bare GET does not shadow get-one (Go 1.22 routing precedence).
// 4. prefix/env filters work and are injection-safe (safePath + org
// confinement).
package kms
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
// seedSecret POSTs a secret through the real handler (which also writes the
// version + mtime sibling records). org is the URL org segment; tok must be
// authorized for it.
func seedSecret(t *testing.T, srvURL, tok, org, path, name, env, value string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"path": path, "name": name, "env": env, "value": value})
req, _ := http.NewRequest("POST", srvURL+"/v1/kms/orgs/"+org+"/secrets", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("seed %s/%s: %v", path, name, err)
}
resp.Body.Close()
if resp.StatusCode != 201 {
t.Fatalf("seed %s/%s: want 201, got %d", path, name, resp.StatusCode)
}
}
// listRaw performs the bare GET list and returns (status, rawBody).
func listRaw(t *testing.T, srvURL, tok, org, query string) (int, string) {
t.Helper()
url := srvURL + "/v1/kms/orgs/" + org + "/secrets"
if query != "" {
url += "?" + query
}
req, _ := http.NewRequest("GET", url, nil)
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("list: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
type listResponse struct {
Secrets []map[string]json.RawMessage `json:"secrets"`
Count int `json:"count"`
Truncated bool `json:"truncated"`
}
func parseList(t *testing.T, raw string) listResponse {
t.Helper()
var lr listResponse
if err := json.Unmarshal([]byte(raw), &lr); err != nil {
t.Fatalf("parse list response %q: %v", raw, err)
}
return lr
}
// allowedRowKeys is the exact set of keys a metadata row may carry. Anything
// else (especially "value"/"ciphertext") is a leak.
var allowedRowKeys = map[string]bool{
"path": true, "name": true, "env": true, "version": true, "updatedTime": true,
}
// assertNoValueLeak fails if any value-bearing key or the sentinel value
// appears anywhere in the response.
func assertNoValueLeak(t *testing.T, raw, sentinel string, lr listResponse) {
t.Helper()
for _, bad := range []string{`"value"`, `"ciphertext"`, `"wrapped_dek"`, `"secretValue"`, `"secretKey"`, sentinel} {
if bad != "" && strings.Contains(raw, bad) {
t.Fatalf("value leak: response contains %q\nbody=%s", bad, raw)
}
}
for i, row := range lr.Secrets {
for k := range row {
if !allowedRowKeys[k] {
t.Fatalf("row %d has disallowed key %q (possible leak)\nbody=%s", i, k, raw)
}
}
}
}
// (a) authorized list returns metadata rows.
func TestList_AuthorizedReturnsMetadata(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "S1")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/stripe", "LIVE_KEY", "default", "S2")
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
if status != 200 {
t.Fatalf("want 200, got %d: %s", status, raw)
}
lr := parseList(t, raw)
if lr.Count != 2 || len(lr.Secrets) != 2 {
t.Fatalf("want count 2, got %d: %s", lr.Count, raw)
}
// Every row carries the metadata fields; version >= 1; paths are present.
seenPlivo, seenStripe := false, false
for _, row := range lr.Secrets {
var path, name string
var version int64
json.Unmarshal(row["path"], &path)
json.Unmarshal(row["name"], &name)
if _, ok := row["version"]; !ok {
t.Fatalf("row missing version: %v", row)
}
json.Unmarshal(row["version"], &version)
if version < 1 {
t.Fatalf("want version >= 1, got %d", version)
}
if path == "brand/hanzo/plivo" && name == "AUTH_TOKEN" {
seenPlivo = true
}
if path == "brand/hanzo/stripe" && name == "LIVE_KEY" {
seenStripe = true
}
}
if !seenPlivo || !seenStripe {
t.Fatalf("missing expected rows: plivo=%v stripe=%v\n%s", seenPlivo, seenStripe, raw)
}
}
// (b) unauthorized: wrong owner → 403; no token → 401. The list must be no
// weaker than get-one.
func TestList_AuthzFailClosed(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
// Seed as hanzo so there IS data to (not) leak.
hanzoTok := mintToken(t, "hanzo", "user-1")
seedSecret(t, srv.URL, hanzoTok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "TOPSECRET")
// Wrong owner, no role → 403.
evil := mintToken(t, "evil-org", "attacker")
status, raw := listRaw(t, srv.URL, evil, "hanzo", "")
if status != 403 {
t.Fatalf("wrong-owner list: want 403, got %d: %s", status, raw)
}
if strings.Contains(raw, "TOPSECRET") || strings.Contains(raw, "AUTH_TOKEN") {
t.Fatalf("403 response leaked metadata/value: %s", raw)
}
// No token → 401.
status, _ = listRaw(t, srv.URL, "", "hanzo", "")
if status != 401 {
t.Fatalf("no-token list: want 401, got %d", status)
}
// Admin (role, different owner) may list via canActOnOrg → 200.
admin := mintToken(t, "ops", "admin-1", "superadmin")
status, _ = listRaw(t, srv.URL, admin, "hanzo", "")
if status != 200 {
t.Fatalf("admin list: want 200, got %d", status)
}
}
// (c) response JSON contains NO value field — parse and assert, and the
// sentinel value never appears.
func TestList_NoValueLeak(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
const sentinel = "PLIVO_AUTH_TOKEN_5551234_DO_NOT_LEAK"
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", sentinel)
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
if status != 200 {
t.Fatalf("want 200, got %d: %s", status, raw)
}
lr := parseList(t, raw)
if lr.Count != 1 {
t.Fatalf("want 1 row, got %d", lr.Count)
}
assertNoValueLeak(t, raw, sentinel, lr)
}
// (d) prefix filter works and is injection-safe.
func TestList_PrefixFilterAndInjection(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "S1")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "ACCOUNT_SID", "default", "S2")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/stripe", "LIVE_KEY", "default", "S3")
// Narrow to plivo only.
status, raw := listRaw(t, srv.URL, tok, "hanzo", "prefix=brand/hanzo/plivo/")
if status != 200 {
t.Fatalf("prefix list: want 200, got %d: %s", status, raw)
}
lr := parseList(t, raw)
if lr.Count != 2 {
t.Fatalf("prefix=plivo: want 2 rows, got %d: %s", lr.Count, raw)
}
for _, row := range lr.Secrets {
var path string
json.Unmarshal(row["path"], &path)
if !strings.HasPrefix(path, "brand/hanzo/plivo") {
t.Fatalf("prefix filter bleed: got path %q", path)
}
}
// Injection-safe: traversal, double-slash, null byte → 400; cross-org
// prefix → 403. None may return 200.
injection := map[string]int{
"prefix=brand/hanzo/../lux/": 400, // ".." segment rejected by safePath
"prefix=brand/hanzo//x/": 400, // "//" rejected by safePath
"prefix=brand/hanzo/%00": 400, // null byte rejected by safePath
"prefix=brand/lux/": 403, // valid path but outside org namespace
"prefix=../../etc/": 400, // traversal
}
for q, want := range injection {
status, raw := listRaw(t, srv.URL, tok, "hanzo", q)
if status != want {
t.Fatalf("injection %q: want %d, got %d: %s", q, want, status, raw)
}
if status == 200 {
t.Fatalf("injection %q must not return 200", q)
}
}
}
// env filter narrows to one environment.
func TestList_EnvFilter(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "dev", "D")
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "prod", "P")
status, raw := listRaw(t, srv.URL, tok, "hanzo", "env=dev")
if status != 200 {
t.Fatalf("env list: want 200, got %d", status)
}
lr := parseList(t, raw)
if lr.Count != 1 {
t.Fatalf("env=dev: want 1 row, got %d: %s", lr.Count, raw)
}
var env string
json.Unmarshal(lr.Secrets[0]["env"], &env)
if env != "dev" {
t.Fatalf("env filter: want dev, got %q", env)
}
}
// (e) empty org → empty list, count 0 (and an empty [], not null).
func TestList_EmptyOrg(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
if status != 200 {
t.Fatalf("empty list: want 200, got %d", status)
}
lr := parseList(t, raw)
if lr.Count != 0 || len(lr.Secrets) != 0 {
t.Fatalf("empty org: want count 0, got %d: %s", lr.Count, raw)
}
if !strings.Contains(raw, `"secrets":[]`) {
t.Fatalf("empty list should serialize secrets as [], got: %s", raw)
}
}
// (f) the bare GET does not shadow get-one: a real get-one still returns its
// value to an authorized caller, while the bare GET returns the metadata list.
func TestList_RoutingPrecedence_NoShadowGetOne(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
const sentinel = "GET_ONE_STILL_WORKS_42"
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", sentinel)
// get-one: the wildcard route still resolves and returns the value.
req, _ := http.NewRequest("GET",
srv.URL+"/v1/kms/orgs/hanzo/secrets/brand/hanzo/plivo/AUTH_TOKEN?env=default", nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var got map[string]map[string]string
json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("get-one: want 200, got %d", resp.StatusCode)
}
if got["secret"]["value"] != sentinel {
t.Fatalf("get-one shadowed: want value %q, got %q", sentinel, got["secret"]["value"])
}
// bare GET: hits list, returns metadata (no value), count 1.
status, raw := listRaw(t, srv.URL, tok, "hanzo", "")
if status != 200 {
t.Fatalf("bare list: want 200, got %d: %s", status, raw)
}
if !strings.Contains(raw, `"count"`) {
t.Fatalf("bare GET did not hit list handler (no count field): %s", raw)
}
lr := parseList(t, raw)
if lr.Count != 1 {
t.Fatalf("bare list: want 1 row, got %d", lr.Count)
}
assertNoValueLeak(t, raw, sentinel, lr)
}
// updatedTime is populated by the write path (mtime sibling index) and is
// valid RFC3339. Proves the metadata mtime tracking is real, not faked.
func TestList_UpdatedTimeIsRFC3339(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
before := time.Now().Add(-2 * time.Second)
seedSecret(t, srv.URL, tok, "hanzo", "brand/hanzo/plivo", "AUTH_TOKEN", "default", "S1")
_, raw := listRaw(t, srv.URL, tok, "hanzo", "")
lr := parseList(t, raw)
if lr.Count != 1 {
t.Fatalf("want 1 row, got %d", lr.Count)
}
rawTime, ok := lr.Secrets[0]["updatedTime"]
if !ok {
t.Fatalf("updatedTime missing for a freshly written secret: %s", raw)
}
var ts string
json.Unmarshal(rawTime, &ts)
parsed, err := time.Parse(time.RFC3339, ts)
if err != nil {
t.Fatalf("updatedTime %q not RFC3339: %v", ts, err)
}
if parsed.Before(before) {
t.Fatalf("updatedTime %v is before the write started %v", parsed, before)
}
}
// Admin is confined to the org namespace on the prefix just like everyone
// else: an admin under /orgs/hanzo cannot enumerate brand/lux/ via prefix.
// (Cross-org browse is done by addressing the other org's URL, not by
// escaping the prefix.)
func TestList_PrefixConfinementUniformForAdmin(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
admin := mintToken(t, "ops", "admin-1", "superadmin")
status, raw := listRaw(t, srv.URL, admin, "hanzo", "prefix=brand/lux/")
if status != 403 {
t.Fatalf("admin cross-namespace prefix: want 403, got %d: %s", status, raw)
}
}