feat(kms): consume per-authority path scopes in consensus snapshot

Activate least-privilege on the /v1/sdk enveloped secrets plane
(task #53). The enforcement primitive (zapserver.ScopedAuthorityProvider)
shipped in 4ab2015; this wires the kmsd CONSUMER side so a scoped
snapshot actually confines a bound NodeID to its granted subtree.

- consensusSnapshot gains an optional `scopes` block: per-authority
  (validator-read / operator-write) NodeID->path-prefix grants. Absent
  (nil) => flat authority + a boot WARN that a compromised member key
  can reach the entire secret tree. Present => NewScopedAuthorityProvider
  (un-granted members fail closed). The two authorities scope
  independently.
- Inner scope maps carry NO omitempty so "scoped, zero grants" ({} =>
  deny every member) survives the JSON round trip instead of failing
  OPEN to flat.
- Malformed scope key (non-NodeID) refuses to boot (fail closed on bad
  input). Self-test tolerates a scoped deny (a Deny is not a
  construction failure; only a transient error is).
- END-TO-END proof through verifyAndAuthorize (real signed ML-DSA-65
  envelope, not just unit Authorize): in-scope read of own secret
  allowed; sibling / straddle (commerce vs commerce-evil) / parent /
  no-grant denied; write confined by the tighter operator scope,
  independent of the read scope. Flat back-compat stays green.

Back-compat: env-var carriage and scope-less snapshots keep the prior
role-based posture. Inert until the operator emits scopes.

Refs #53
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-03 11:42:39 -07:00
parent 4ab2015197
commit ca9332b9f0
3 changed files with 568 additions and 22 deletions
+125 -22
View File
@@ -21,6 +21,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
@@ -75,9 +76,48 @@ func buildNonceLedger() (zapserver.NonceLedger, error) {
// consensusSnapshot is the wire shape of the JSON file the operator
// drops into the kmsd container. Same shape as the env vars (one
// authority per slice) so the operator can pick either delivery.
//
// Scopes is the OPTIONAL least-privilege overlay. When present it confines
// each member NodeID of an authority to a path prefix, so a single
// compromised service key cannot reach the entire secret tree. Absent (nil)
// leaves both authorities flat (unconfined) — the pre-existing role-based
// posture and the only posture the env-var carriage can express. The scope
// is AUTHORITY-side data keyed by the cryptographically-bound NodeID; it is
// never taken from the caller's self-declared envelope path.
type consensusSnapshot struct {
Validators []string `json:"validators"`
Operators []string `json:"operators"`
Validators []string `json:"validators"`
Operators []string `json:"operators"`
Scopes *consensusScopes `json:"scopes,omitempty"`
}
// consensusScopes carries the per-authority NodeID→path-prefix grants. A
// non-nil authority map opts that authority into least-privilege scoping:
// a member absent from the map is DENIED every path (explicit grant
// required — fail closed). A nil authority map leaves that authority flat
// (every member unconfined). The two authorities are independent: the read
// (validator) authority can be scoped while the write (operator) authority
// stays flat, or vice-versa.
//
// The map fields deliberately carry NO omitempty: a present-but-empty map
// ("scoped, zero grants" → deny every member) must survive the JSON round
// trip as `{}` and NOT be silently dropped to null/nil (which would fail
// OPEN to flat). nil marshals to `null` (flat); `{}` stays `{}` (scoped).
type consensusScopes struct {
Validators map[string]string `json:"validators"`
Operators map[string]string `json:"operators"`
}
// parsedSnapshot is the fully-parsed authority data: the two NodeID member
// sets plus the optional per-authority scope maps, keyed by the parsed
// NodeID so the provider constructors can consume them directly. A nil
// scope map means the authority is flat (unconfined); a non-nil map (even
// empty) means the authority is scoped and fail-closed for un-granted
// members.
type parsedSnapshot struct {
validators []ids.NodeID
operators []ids.NodeID
validatorScopes map[ids.NodeID]string
operatorScopes map[ids.NodeID]string
}
// buildConsensusAuthorizer constructs the ConsensusAuthorizer wired
@@ -85,14 +125,14 @@ type consensusSnapshot struct {
// vars; refuses to boot if neither is present, since the ZAP server
// is fail-closed by construction.
func buildConsensusAuthorizer() (zapserver.ConsensusAuthorizer, error) {
validators, operators, err := loadConsensusSnapshot()
snap, err := loadConsensusSnapshot()
if err != nil {
return nil, err
}
if len(validators) == 0 {
if len(snap.validators) == 0 {
return nil, errors.New("consensus validator authority is empty (refusing to boot fail-open)")
}
if len(operators) == 0 {
if len(snap.operators) == 0 {
return nil, errors.New("consensus operator authority is empty (refusing to boot fail-open)")
}
ttl := defaultConsensusTTL
@@ -104,55 +144,118 @@ func buildConsensusAuthorizer() (zapserver.ConsensusAuthorizer, error) {
ttl = d
}
az, err := zapserver.NewInProcessAuthorizer(zapserver.InProcessAuthorizerConfig{
Validators: zapserver.NewStaticAuthorityProvider(validators),
Operator: zapserver.NewStaticAuthorityProvider(operators),
Validators: newAuthorityProvider(snap.validators, snap.validatorScopes, "validator/read"),
Operator: newAuthorityProvider(snap.operators, snap.operatorScopes, "operator/write"),
CacheTTL: ttl,
})
if err != nil {
return nil, err
}
// Probe both providers once so a malformed snapshot surfaces here
// (in the fatal path) rather than on the first inbound request.
// Probe the authorizer once so a malformed snapshot surfaces here (in
// the fatal path) rather than on the first inbound request. A scoped
// authority may legitimately DENY the probe path — that is not a
// construction failure, so we only reject a non-nil transient error
// (nil map, provider dial failure), never a clean Deny.
if _, err := az.Authorize(context.Background(), zapserver.Identity{
NodeID: validators[0],
NodeID: snap.validators[0],
}, "self-test", zapserver.OpAuthGet); err != nil {
return nil, fmt.Errorf("authorizer self-test: %w", err)
}
return az, nil
}
// loadConsensusSnapshot returns the (validators, operators) NodeID
// sets, sourcing from KMS_CONSENSUS_FILE first then falling back to
// KMS_CONSENSUS_VALIDATORS + KMS_CONSENSUS_OPERATORS env vars.
func loadConsensusSnapshot() ([]ids.NodeID, []ids.NodeID, error) {
// newAuthorityProvider builds the AuthorityProvider for one authority. A
// nil scope map yields a flat (unconfined) provider and logs a WARN — a
// compromised member key of a flat authority can reach the entire secret
// tree, so an unscoped production authority is a blast-radius liability the
// operator should close by emitting scopes.<authority>. A non-nil scope map
// yields a least-privilege scoped provider (members confined to their
// granted prefix; un-granted members fail closed).
func newAuthorityProvider(members []ids.NodeID, scopes map[ids.NodeID]string, name string) zapserver.AuthorityProvider {
if scopes == nil {
log.Printf("kms: WARNING: consensus %s authority is UNCONFINED (%d members, no scopes) — "+
"a compromised member key can reach the ENTIRE secret tree; emit scopes to enable least-privilege",
name, len(members))
return zapserver.NewStaticAuthorityProvider(members)
}
log.Printf("kms: consensus %s authority scoped (%d members, %d grants) — least-privilege enforced",
name, len(members), len(scopes))
return zapserver.NewScopedAuthorityProvider(members, scopes)
}
// loadConsensusSnapshot returns the fully-parsed authority data
// (validators + operators NodeID sets, plus optional per-authority scope
// maps), sourcing from KMS_CONSENSUS_FILE first then falling back to
// KMS_CONSENSUS_VALIDATORS + KMS_CONSENSUS_OPERATORS env vars. The env-var
// carriage has no scope channel, so it always yields flat authorities.
func loadConsensusSnapshot() (parsedSnapshot, error) {
if path := strings.TrimSpace(os.Getenv(envFile)); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", envFile, err)
return parsedSnapshot{}, fmt.Errorf("%s: %w", envFile, err)
}
var snap consensusSnapshot
if err := json.Unmarshal(data, &snap); err != nil {
return nil, nil, fmt.Errorf("%s: %w", envFile, err)
return parsedSnapshot{}, fmt.Errorf("%s: %w", envFile, err)
}
validators, err := parseNodeIDs(snap.Validators)
if err != nil {
return nil, nil, fmt.Errorf("%s validators: %w", envFile, err)
return parsedSnapshot{}, fmt.Errorf("%s validators: %w", envFile, err)
}
operators, err := parseNodeIDs(snap.Operators)
if err != nil {
return nil, nil, fmt.Errorf("%s operators: %w", envFile, err)
return parsedSnapshot{}, fmt.Errorf("%s operators: %w", envFile, err)
}
return validators, operators, nil
ps := parsedSnapshot{validators: validators, operators: operators}
if snap.Scopes != nil {
if snap.Scopes.Validators != nil {
ps.validatorScopes, err = parseScopeMap(snap.Scopes.Validators)
if err != nil {
return parsedSnapshot{}, fmt.Errorf("%s scopes.validators: %w", envFile, err)
}
}
if snap.Scopes.Operators != nil {
ps.operatorScopes, err = parseScopeMap(snap.Scopes.Operators)
if err != nil {
return parsedSnapshot{}, fmt.Errorf("%s scopes.operators: %w", envFile, err)
}
}
}
return ps, nil
}
validators, err := parseNodeIDs(splitLines(os.Getenv(envValidators)))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", envValidators, err)
return parsedSnapshot{}, fmt.Errorf("%s: %w", envValidators, err)
}
operators, err := parseNodeIDs(splitLines(os.Getenv(envOperators)))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", envOperators, err)
return parsedSnapshot{}, fmt.Errorf("%s: %w", envOperators, err)
}
return validators, operators, nil
return parsedSnapshot{validators: validators, operators: operators}, nil
}
// parseScopeMap parses a NodeID-string→path-prefix map into a
// NodeID→prefix map. Each key MUST be a valid NodeID (a typo is a hard
// failure — refusing to boot with a malformed scope grant, matching
// parseNodeIDs). The prefix value is normalized (surrounding whitespace and
// slashes trimmed) so scope containment compares canonical "/"-joined
// paths; an empty prefix means the member is unconfined (root). The result
// is non-nil even for an empty input so the caller distinguishes "scoped,
// zero grants" (fail-closed for every member) from "flat, no scopes" (nil).
func parseScopeMap(raw map[string]string) (map[ids.NodeID]string, error) {
out := make(map[ids.NodeID]string, len(raw))
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
id, err := ids.NodeIDFromString(k)
if err != nil {
return nil, fmt.Errorf("scope key %q: %w", k, err)
}
out[id] = strings.Trim(strings.TrimSpace(v), "/")
}
return out, nil
}
// splitLines splits on newlines, commas, and whitespace. Empty
+201
View File
@@ -0,0 +1,201 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// consensus_scope_test.go — coverage for the kmsd's consumption of the
// least-privilege `scopes` overlay in the consensus snapshot
// (KMS_CONSENSUS_FILE). Proves the boot-time wiring turns a scoped snapshot
// into a scoped authorizer (straddle / parent / sibling / no-grant all
// deny), that a flat snapshot stays unconfined (back-compat), and that a
// malformed scope grant refuses to boot (fail closed on bad input).
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/kms/pkg/zapserver"
)
// scopeTestNode returns a deterministic non-zero NodeID whose String()
// round-trips through ids.NodeIDFromString (the parse path the snapshot
// loader uses).
func scopeTestNode(seed byte) ids.NodeID {
var n ids.NodeID
for i := range n {
n[i] = seed
}
return n
}
// writeSnapshot marshals snap to a temp file and points KMS_CONSENSUS_FILE
// at it, clearing the env-var carriage so the file path is authoritative.
func writeSnapshot(t *testing.T, snap consensusSnapshot) {
t.Helper()
data, err := json.Marshal(snap)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
path := filepath.Join(t.TempDir(), "consensus-authority.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write snapshot: %v", err)
}
t.Setenv(envValidators, "")
t.Setenv(envOperators, "")
t.Setenv(envTTL, "")
t.Setenv(envFile, path)
}
func mustAuthz(t *testing.T) zapserver.ConsensusAuthorizer {
t.Helper()
az, err := buildConsensusAuthorizer()
if err != nil {
t.Fatalf("buildConsensusAuthorizer: %v", err)
}
return az
}
func allow(t *testing.T, az zapserver.ConsensusAuthorizer, node ids.NodeID, path string, op zapserver.Op) {
t.Helper()
d, err := az.Authorize(context.Background(), zapserver.Identity{NodeID: node}, path, op)
if err != nil {
t.Fatalf("Authorize(%s, %q) unexpected err: %v", op, path, err)
}
if !d.Allow {
t.Fatalf("Authorize(%s, %q) must ALLOW, got deny(%s)", op, path, d.Reason)
}
}
func deny(t *testing.T, az zapserver.ConsensusAuthorizer, node ids.NodeID, path string, op zapserver.Op) {
t.Helper()
d, err := az.Authorize(context.Background(), zapserver.Identity{NodeID: node}, path, op)
if err != nil {
t.Fatalf("Authorize(%s, %q) unexpected err: %v", op, path, err)
}
if d.Allow {
t.Fatalf("Authorize(%s, %q) must DENY (scope), got allow", op, path)
}
}
// TestConsensusScopes_ScopedSnapshot_Enforced — a snapshot carrying a
// scopes.validators grant confines the reader to its subtree; a co-member
// with no grant fails closed.
func TestConsensusScopes_ScopedSnapshot_Enforced(t *testing.T) {
commerce := scopeTestNode(0x11)
kms := scopeTestNode(0x22)
writeSnapshot(t, consensusSnapshot{
Validators: []string{commerce.String(), kms.String()},
Operators: []string{commerce.String()},
Scopes: &consensusScopes{
Validators: map[string]string{commerce.String(): "hanzo/commerce"},
// kms is a member but carries no grant → fail closed.
Operators: map[string]string{commerce.String(): "hanzo/commerce"},
},
})
az := mustAuthz(t)
// commerce reads its own subtree.
allow(t, az, commerce, "hanzo/commerce", zapserver.OpAuthGet)
allow(t, az, commerce, "hanzo/commerce/db", zapserver.OpAuthGet)
// commerce cannot escape its subtree.
deny(t, az, commerce, "hanzo", zapserver.OpAuthGet) // parent
deny(t, az, commerce, "hanzo/kms", zapserver.OpAuthGet) // sibling
deny(t, az, commerce, "hanzo/commerce-evil", zapserver.OpAuthGet) // straddle
deny(t, az, commerce, "hanzo/commerce-evil/x", zapserver.OpAuthGet)
// kms is a scoped member with NO grant → denied everything (fail closed).
deny(t, az, kms, "hanzo/kms", zapserver.OpAuthGet)
deny(t, az, kms, "hanzo/commerce", zapserver.OpAuthGet)
// In-scope write for commerce (member of the operator authority too).
allow(t, az, commerce, "hanzo/commerce/api-key", zapserver.OpAuthPut)
deny(t, az, commerce, "hanzo/kms", zapserver.OpAuthPut)
}
// TestConsensusScopes_FlatSnapshot_Unconfined — a snapshot WITHOUT a scopes
// block leaves both authorities flat: every member reads/writes any path
// (the pre-existing role-based posture; back-compat).
func TestConsensusScopes_FlatSnapshot_Unconfined(t *testing.T) {
a := scopeTestNode(0x33)
writeSnapshot(t, consensusSnapshot{
Validators: []string{a.String()},
Operators: []string{a.String()},
// No Scopes.
})
az := mustAuthz(t)
for _, p := range []string{"hanzo/commerce", "lux/bridge", "anything/at/all"} {
allow(t, az, a, p, zapserver.OpAuthGet)
allow(t, az, a, p, zapserver.OpAuthPut)
}
}
// TestConsensusScopes_PartialScope_ReadScopedWriteFlat — the two authorities
// scope independently: scopes.validators present (read confined) while
// scopes.operators absent (write flat).
func TestConsensusScopes_PartialScope_ReadScopedWriteFlat(t *testing.T) {
a := scopeTestNode(0x44)
writeSnapshot(t, consensusSnapshot{
Validators: []string{a.String()},
Operators: []string{a.String()},
Scopes: &consensusScopes{
Validators: map[string]string{a.String(): "hanzo/commerce"},
// Operators nil → flat write authority.
},
})
az := mustAuthz(t)
// Read confined.
allow(t, az, a, "hanzo/commerce/x", zapserver.OpAuthGet)
deny(t, az, a, "hanzo/kms", zapserver.OpAuthGet)
// Write flat (unconfined) — a is in the operator set with no operator
// scope, so the write scope check is a no-op. It must still pass the
// validator gate+scope first, so pick an in-read-scope path.
allow(t, az, a, "hanzo/commerce/x", zapserver.OpAuthPut)
}
// TestConsensusScopes_MalformedScopeKey_RefusesToBoot — a scope grant keyed
// by a non-NodeID string is a hard failure. The kmsd refuses to construct
// the authorizer rather than silently drop the grant (which would fail-open
// a member to unconfined or drop it entirely).
func TestConsensusScopes_MalformedScopeKey_RefusesToBoot(t *testing.T) {
a := scopeTestNode(0x55)
writeSnapshot(t, consensusSnapshot{
Validators: []string{a.String()},
Operators: []string{a.String()},
Scopes: &consensusScopes{
Validators: map[string]string{"not-a-valid-nodeid": "hanzo/commerce"},
},
})
if _, err := buildConsensusAuthorizer(); err == nil {
t.Fatalf("malformed scope key must refuse to boot, got nil error")
}
}
// TestConsensusScopes_EmptyScopeMap_FailsClosed — a scopes.validators that
// is present but empty opts the authority into scoping with ZERO grants:
// every member is denied (fail closed), NOT defaulted to unconfined. Guards
// against a "present but empty" snapshot silently disabling confinement.
func TestConsensusScopes_EmptyScopeMap_FailsClosed(t *testing.T) {
a := scopeTestNode(0x66)
writeSnapshot(t, consensusSnapshot{
Validators: []string{a.String()},
Operators: []string{a.String()},
Scopes: &consensusScopes{
Validators: map[string]string{}, // present, empty → scoped, no grants
},
})
az := mustAuthz(t)
deny(t, az, a, "hanzo/commerce", zapserver.OpAuthGet)
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// path_scope_e2e_test.go — END-TO-END proof that authority-anchored path
// scoping holds through the FULL request path (verifyAndAuthorize: parse
// envelope → verify ML-DSA-65 signature + binding + freshness + replay →
// extract addressed path → consensus authorize), not just the unit
// Authorize entry point.
//
// This is the RED re-review gate for task #53 (KMS path-scope activation):
// a scoped authority snapshot must confine a cryptographically-bound NodeID
// to its granted subtree even when the caller signs a perfectly valid
// envelope addressing a DIFFERENT subtree. The scope is authority-side data
// keyed by the bound NodeID; the caller cannot widen it by setting the
// envelope's inner `path` to anything it likes — that path is exactly what
// gets confinement-checked.
//
// Coverage (all driven with a real signed envelope):
//
// - in-scope read of the service's OWN secret → allowed end-to-end
// (verifyAndAuthorize + handleGet returns the sealed value);
// - sibling subtree, straddling prefix (commerce vs commerce-evil),
// parent escape → denied;
// - a scoped member with NO grant → denied every path (fail closed);
// - write confinement by the OPERATOR authority's (tighter) scope,
// isolated from the read (validator) scope;
// - flat (unscoped) authority stays unconfined (back-compat — covered
// additionally by server_auth_test.go's role tests).
package zapserver
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/kms/pkg/store"
"github.com/luxfi/log"
badger "github.com/luxfi/zapdb"
)
// newScopedTestServer mirrors newTestServer but wires per-authority scope
// maps. A nil scope map yields a flat (unconfined) provider; a non-nil map
// yields a least-privilege scoped provider (un-granted members fail closed).
func newScopedTestServer(t *testing.T, validators, operators []ids.NodeID, valScopes, opScopes map[ids.NodeID]string) *Server {
t.Helper()
opts := badger.DefaultOptions("").WithInMemory(true)
db, err := badger.Open(opts)
if err != nil {
t.Fatalf("open zapdb: %v", err)
}
t.Cleanup(func() { db.Close() })
mk := make([]byte, 32)
if _, err := rand.Read(mk); err != nil {
t.Fatalf("rand: %v", err)
}
authz, err := NewInProcessAuthorizer(InProcessAuthorizerConfig{
Validators: scopedOrFlat(validators, valScopes),
Operator: scopedOrFlat(operators, opScopes),
})
if err != nil {
t.Fatalf("authorizer: %v", err)
}
return New(Config{
Store: store.NewSecretStore(db),
MasterKey: mk,
Authorizer: authz,
Logger: log.NewNoOpLogger(),
Now: func() time.Time { return time.Unix(1_717_200_000, 0) },
})
}
// scopedOrFlat picks the provider constructor matching the intended posture:
// nil scopes → flat, non-nil → scoped.
func scopedOrFlat(members []ids.NodeID, scopes map[ids.NodeID]string) AuthorityProvider {
if scopes == nil {
return NewStaticAuthorityProvider(members)
}
return NewScopedAuthorityProvider(members, scopes)
}
// TestScopedE2E_ReadOwnSubtree_Allowed proves a scoped validator reads its
// OWN secret end-to-end: signed envelope → verify → authorize → handleGet
// returns the sealed plaintext. This is the "service reads its own subtree"
// happy path the least-privilege boundary must preserve.
func TestScopedE2E_ReadOwnSubtree_Allowed(t *testing.T) {
ident := newIdentity(t, "hanzo/commerce")
defer ident.Wipe()
s := newScopedTestServer(t,
[]ids.NodeID{ident.NodeID}, nil,
map[ids.NodeID]string{ident.NodeID: "hanzo/commerce"}, nil,
)
seed(t, s, "hanzo/commerce", "api-key", "prod", "sk_live_own")
now := time.Unix(1_717_200_000, 0)
// Scope root itself.
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
Path: "hanzo/commerce", Name: "api-key", Env: "prod",
}), now, "scope-root-nonce")
vident, payload, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet)
if err != nil {
t.Fatalf("in-scope read must be allowed end-to-end: %v", err)
}
st, body, herr := s.handleGet(context.Background(), vident, payload)
if herr != nil || st != statusOK {
t.Fatalf("handleGet own secret: status=0x%02X err=%v", st, herr)
}
var gr getResp
if err := json.Unmarshal(body, &gr); err != nil {
t.Fatalf("decode getResp: %v", err)
}
if dec, _ := base64.StdEncoding.DecodeString(gr.Value); string(dec) != "sk_live_own" {
t.Fatalf("own-secret value mismatch: got %q want sk_live_own", string(dec))
}
// A descendant path is inside the scope too.
rawDesc := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
Path: "hanzo/commerce/db", Name: "x", Env: "prod",
}), now, "scope-desc-nonce")
if _, _, err := s.verifyAndAuthorize(context.Background(), rawDesc, OpSecretGet); err != nil {
t.Fatalf("descendant read must be allowed: %v", err)
}
}
// TestScopedE2E_ReadOutsideScope_Denied drives the three escape shapes RED
// cares about through the full envelope path: sibling subtree, straddling
// prefix (commerce vs commerce-evil), and parent escape. Each is a
// signature-valid envelope from the correct NodeID — only the scope check
// stops it.
func TestScopedE2E_ReadOutsideScope_Denied(t *testing.T) {
ident := newIdentity(t, "hanzo/commerce")
defer ident.Wipe()
s := newScopedTestServer(t,
[]ids.NodeID{ident.NodeID}, nil,
map[ids.NodeID]string{ident.NodeID: "hanzo/commerce"}, nil,
)
now := time.Unix(1_717_200_000, 0)
cases := []struct {
name string
path string
}{
{"sibling-subtree", "hanzo/kms"},
{"sibling-secret", "hanzo/kms/master"},
{"straddling-prefix", "hanzo/commerce-evil"},
{"straddling-secret", "hanzo/commerce-evil/exfil"},
{"parent-escape", "hanzo"},
{"unrelated-root", "lux/bridge"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
Path: c.path, Name: "api-key", Env: "prod",
}), now, "outside-"+c.name+"-nonce")
if _, _, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet); err == nil {
t.Fatalf("read of %q (outside scope hanzo/commerce) MUST be denied end-to-end, got allow", c.path)
}
})
}
}
// TestScopedE2E_ScopedMemberNoGrant_Denied — opting the validator authority
// into scoping makes an explicit grant mandatory. A NodeID that is a MEMBER
// but carries no scope entry is denied every path (fail closed), proven
// through the full envelope path.
func TestScopedE2E_ScopedMemberNoGrant_Denied(t *testing.T) {
ident := newIdentity(t, "hanzo/commerce")
defer ident.Wipe()
// Non-nil (scoped) validator authority, but the grant map is empty:
// ident is a member with NO grant.
s := newScopedTestServer(t,
[]ids.NodeID{ident.NodeID}, nil,
map[ids.NodeID]string{}, nil,
)
now := time.Unix(1_717_200_000, 0)
raw := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
Path: "hanzo/commerce", Name: "api-key", Env: "prod",
}), now, "no-grant-nonce")
if _, _, err := s.verifyAndAuthorize(context.Background(), raw, OpSecretGet); err == nil {
t.Fatalf("scoped member with no grant MUST be denied (fail closed), got allow")
}
}
// TestScopedE2E_WriteConfinedByOperatorScope isolates the OPERATOR (write)
// authority's scope from the validator (read) scope. The identity may READ
// broadly across "hanzo" but WRITE only within "hanzo/commerce": a Put to
// "hanzo/kms/x" passes the validator gate+scope yet is stopped by the
// tighter operator scope. This proves the write grant is independently
// enforced end-to-end.
func TestScopedE2E_WriteConfinedByOperatorScope(t *testing.T) {
ident := newIdentity(t, "hanzo/commerce")
defer ident.Wipe()
s := newScopedTestServer(t,
[]ids.NodeID{ident.NodeID}, []ids.NodeID{ident.NodeID},
map[ids.NodeID]string{ident.NodeID: "hanzo"}, // broad READ
map[ids.NodeID]string{ident.NodeID: "hanzo/commerce"}, // tight WRITE
)
now := time.Unix(1_717_200_000, 0)
// In-scope write.
putIn := signedEnvelopeBytes(t, ident, OpSecretPut, buildInner(t, putReq{
Path: "hanzo/commerce", Name: "rotation-key", Env: "prod",
Value: base64.StdEncoding.EncodeToString([]byte("newSecret")),
}), now, "write-in-nonce")
vident, payload, err := s.verifyAndAuthorize(context.Background(), putIn, OpSecretPut)
if err != nil {
t.Fatalf("in-scope write must be allowed: %v", err)
}
if st, _, herr := s.handlePut(context.Background(), vident, payload); herr != nil || st != statusOK {
t.Fatalf("handlePut in-scope: status=0x%02X err=%v", st, herr)
}
// Out-of-write-scope Put (still inside the broad READ scope "hanzo",
// so the denial is unambiguously the OPERATOR scope).
putOut := signedEnvelopeBytes(t, ident, OpSecretPut, buildInner(t, putReq{
Path: "hanzo/kms", Name: "master", Env: "prod",
Value: base64.StdEncoding.EncodeToString([]byte("evil")),
}), now, "write-out-nonce")
if _, _, err := s.verifyAndAuthorize(context.Background(), putOut, OpSecretPut); err == nil {
t.Fatalf("write to hanzo/kms (inside read scope, outside write scope) MUST be denied, got allow")
}
// A read of the same out-of-write-scope path IS allowed — the read scope
// is broader. This pins that read and write scopes are enforced
// independently, not conflated.
readOut := signedEnvelopeBytes(t, ident, OpSecretGet, buildInner(t, getReq{
Path: "hanzo/kms", Name: "master", Env: "prod",
}), now, "read-broad-nonce")
if _, _, err := s.verifyAndAuthorize(context.Background(), readOut, OpSecretGet); err != nil {
t.Fatalf("read within the broad read scope must be allowed: %v", err)
}
}