diff --git a/cmd/kms/consensus.go b/cmd/kms/consensus.go index a6783d925..2125e99ba 100644 --- a/cmd/kms/consensus.go +++ b/cmd/kms/consensus.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "strings" "time" @@ -75,9 +76,22 @@ 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. Absent => nil => no +// scope observation is possible, which is exactly the state every +// currently-deployed snapshot is in. Decoding it is inert: nothing reads +// the overlay unless KMS_AUTHZ_MODE selects a mode that does. type consensusSnapshot struct { - Validators []string `json:"validators"` - Operators []string `json:"operators"` + Validators []string `json:"validators"` + Operators []string `json:"operators"` + Scopes *consensusScopeSet `json:"scopes"` +} + +// consensusScopeSet is keyed by IDENTITY, not by authority: membership +// answers "who are you", grants answer "what may you address". Mirrors +// the kms-operator's bootstrap.AuthorityScopes byte-for-byte. +type consensusScopeSet struct { + Identities map[string]zapserver.Grants `json:"identities"` } // buildConsensusAuthorizer constructs the ConsensusAuthorizer wired @@ -85,7 +99,7 @@ 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() + validators, operators, scopes, err := loadConsensusSnapshot() if err != nil { return nil, err } @@ -118,41 +132,97 @@ func buildConsensusAuthorizer() (zapserver.ConsensusAuthorizer, error) { }, "self-test", zapserver.OpAuthGet); err != nil { return nil, fmt.Errorf("authorizer self-test: %w", err) } - return az, nil + return wrapAuthzMode(az, scopes) } -// 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) { +// wrapAuthzMode installs the scope-observation stage selected by +// KMS_AUTHZ_MODE. +// +// Unset (the state of every deployment today) returns `az` VERBATIM: no +// wrapper, no extra branch, no behaviour change of any kind. A malformed +// value is a hard boot failure so a typo cannot silently degrade to a +// posture nobody chose. +func wrapAuthzMode(az zapserver.ConsensusAuthorizer, scopes map[ids.NodeID]zapserver.Grants) (zapserver.ConsensusAuthorizer, error) { + mode, err := zapserver.ParseAuthzMode(os.Getenv(zapserver.EnvAuthzMode)) + if err != nil { + return nil, err + } + if mode == zapserver.AuthzModeOff { + return az, nil + } + provider := zapserver.NewStaticScopeProvider(scopes) + log.Printf("kms: authz mode=%s (OBSERVE ONLY — no request is denied by scope) identities=%d", mode, provider.Len()) + if provider.Len() == 0 { + log.Printf("kms: authz mode=%s but the consensus snapshot carries no scope overlay — "+ + "every allowed request will be reported as a would-deny", mode) + } + log.Printf("kms: authz audit compares PATH only; the authorizer contract carries neither env nor org, " + + "so real enforcement would deny at least what audit reports and probably more") + return zapserver.WrapScopeAuthorizer(az, zapserver.ScopeAuthorizerConfig{ + Mode: mode, + Scopes: provider, + }) +} + +// loadConsensusSnapshot returns the (validators, operators) NodeID sets +// plus the optional per-identity scope overlay, sourcing from +// KMS_CONSENSUS_FILE first then falling back to KMS_CONSENSUS_VALIDATORS +// + KMS_CONSENSUS_OPERATORS env vars. +// +// The env-var delivery carries no overlay (it is two NodeID lists); a nil +// scope map there is correct, not a gap. +func loadConsensusSnapshot() ([]ids.NodeID, []ids.NodeID, map[ids.NodeID]zapserver.Grants, 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 nil, nil, nil, 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 nil, nil, nil, 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 nil, nil, nil, 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 nil, nil, nil, fmt.Errorf("%s operators: %w", envFile, err) } - return validators, operators, nil + scopes, err := parseScopes(snap.Scopes) + if err != nil { + return nil, nil, nil, fmt.Errorf("%s scopes: %w", envFile, err) + } + return validators, operators, scopes, nil } validators, err := parseNodeIDs(splitLines(os.Getenv(envValidators))) if err != nil { - return nil, nil, fmt.Errorf("%s: %w", envValidators, err) + return nil, nil, nil, 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 nil, nil, nil, fmt.Errorf("%s: %w", envOperators, err) } - return validators, operators, nil + return validators, operators, nil, nil +} + +// parseScopes converts the snapshot's identity→grants block into the +// NodeID-keyed form the scope provider serves. A nil block yields a nil +// map (no overlay). A malformed NodeID is a hard failure — the same +// posture parseNodeIDs takes for the authority lists. +func parseScopes(sc *consensusScopeSet) (map[ids.NodeID]zapserver.Grants, error) { + if sc == nil || sc.Identities == nil { + return nil, nil + } + out := make(map[ids.NodeID]zapserver.Grants, len(sc.Identities)) + for raw, grants := range sc.Identities { + id, err := ids.NodeIDFromString(strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("parse %q: %w", raw, err) + } + out[id] = grants + } + return out, nil } // splitLines splits on newlines, commas, and whitespace. Empty diff --git a/cmd/kms/consensus_scopes_test.go b/cmd/kms/consensus_scopes_test.go new file mode 100644 index 000000000..71e10b657 --- /dev/null +++ b/cmd/kms/consensus_scopes_test.go @@ -0,0 +1,198 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// consensus_scopes_test.go — boot-wiring coverage for the scope overlay. +// +// This is the level that decides whether a deployed pod changes +// behaviour, so it pins the two properties that keep this gate inert: +// +// - every snapshot in the field today (no `scopes` key) still decodes, +// and yields NO overlay; +// - with KMS_AUTHZ_MODE unset, wrapAuthzMode returns the authorizer it +// was handed, verbatim. + +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/luxfi/ids" + "github.com/luxfi/kms/pkg/zapserver" +) + +// writeSnapshot drops a snapshot file and points KMS_CONSENSUS_FILE at it. +func writeSnapshot(t *testing.T, body string) { + t.Helper() + p := filepath.Join(t.TempDir(), "consensus-authority.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write snapshot: %v", err) + } + t.Setenv(envFile, p) +} + +const ( + nodeA = "NodeID-FjdREJWf4CHPfr1DVnQVuMf8ouRVgTMBH" + nodeB = "NodeID-HHsMJb8a4tjymPUcnsZTuqn3WXDhEkUaJ" +) + +// TestLoadConsensusSnapshot_LegacyFileHasNoOverlay — the exact bytes the +// operator emitted before this gate. It must still load, and the overlay +// must be nil (not an empty map, which would read as "scoped, nothing +// granted"). +func TestLoadConsensusSnapshot_LegacyFileHasNoOverlay(t *testing.T) { + writeSnapshot(t, `{"validators":["`+nodeA+`"],"operators":["`+nodeB+`"]}`) + + validators, operators, scopes, err := loadConsensusSnapshot() + if err != nil { + t.Fatalf("legacy snapshot failed to load: %v", err) + } + if len(validators) != 1 || len(operators) != 1 { + t.Fatalf("authority sets misread: %v / %v", validators, operators) + } + if scopes != nil { + t.Fatalf("legacy snapshot produced a non-nil overlay (%v) — absent must mean absent", scopes) + } +} + +// TestLoadConsensusSnapshot_UnknownKeysIgnored — a snapshot from a NEWER +// operator must not break an older kmsd. Forward compatibility is what +// lets the operator ship the overlay before any kmsd consumes it. +func TestLoadConsensusSnapshot_UnknownKeysIgnored(t *testing.T) { + writeSnapshot(t, `{"validators":["`+nodeA+`"],"operators":["`+nodeB+`"],"somethingNew":{"a":1}}`) + if _, _, _, err := loadConsensusSnapshot(); err != nil { + t.Fatalf("unknown key broke the decode: %v", err) + } +} + +// TestLoadConsensusSnapshot_ParsesIdentityScopes — the overlay the +// operator now emits, decoded into the NodeID-keyed form. +func TestLoadConsensusSnapshot_ParsesIdentityScopes(t *testing.T) { + writeSnapshot(t, `{ + "validators":["`+nodeA+`"], + "operators":["`+nodeB+`"], + "scopes":{"identities":{ + "`+nodeB+`":{"unconfined":true,"grants":[]}, + "`+nodeA+`":{"grants":[ + {"org":"hanzo","env":"prod","path":"bootnode"}, + {"org":"hanzo","env":"prod","path":"bootnode-secrets"} + ]} + }} + }`) + + _, _, scopes, err := loadConsensusSnapshot() + if err != nil { + t.Fatalf("scoped snapshot failed to load: %v", err) + } + if len(scopes) != 2 { + t.Fatalf("expected 2 identity entries, got %d: %v", len(scopes), scopes) + } + opID, err := ids.NodeIDFromString(nodeB) + if err != nil { + t.Fatal(err) + } + if !scopes[opID].Unconfined { + t.Error("operator entry lost its Unconfined flag") + } + svcID, err := ids.NodeIDFromString(nodeA) + if err != nil { + t.Fatal(err) + } + svc := scopes[svcID] + if svc.Unconfined { + t.Error("service entry must not be unconfined") + } + if len(svc.Grants) != 2 { + t.Fatalf("grant SET truncated to %d — the many-to-many shape must survive: %v", len(svc.Grants), svc.Grants) + } +} + +// TestLoadConsensusSnapshot_MalformedScopeNodeIDIsFatal — a typo in the +// overlay must fail at boot, matching how the authority lists behave. +func TestLoadConsensusSnapshot_MalformedScopeNodeIDIsFatal(t *testing.T) { + writeSnapshot(t, `{"validators":["`+nodeA+`"],"operators":["`+nodeB+`"], + "scopes":{"identities":{"not-a-node-id":{"grants":[]}}}}`) + if _, _, _, err := loadConsensusSnapshot(); err == nil { + t.Fatal("a malformed NodeID in the overlay must be a hard failure") + } +} + +// TestWrapAuthzMode_UnsetIsIdentity — with KMS_AUTHZ_MODE unset, the +// wiring hands back the SAME authorizer value. This is the property that +// makes deploying this build a no-op for every pod in the field. +func TestWrapAuthzMode_UnsetIsIdentity(t *testing.T) { + t.Setenv(zapserver.EnvAuthzMode, "") + inner := &noopAuthorizer{} + got, err := wrapAuthzMode(inner, map[ids.NodeID]zapserver.Grants{ + {0x01}: {Grants: []zapserver.Grant{{Org: "hanzo", Env: "prod", Path: "x"}}}, + }) + if err != nil { + t.Fatalf("wrapAuthzMode: %v", err) + } + if got != zapserver.ConsensusAuthorizer(inner) { + t.Fatalf("unset mode wrapped the authorizer (%T) — deploying this build would change the request path", got) + } +} + +// TestWrapAuthzMode_AuditWraps — audit installs the observer even when +// the snapshot carries no overlay (in which case every allowed request is +// reported, which is the correct signal: nothing is granted yet). +func TestWrapAuthzMode_AuditWraps(t *testing.T) { + t.Setenv(zapserver.EnvAuthzMode, "audit") + inner := &noopAuthorizer{} + got, err := wrapAuthzMode(inner, nil) + if err != nil { + t.Fatalf("wrapAuthzMode: %v", err) + } + if got == zapserver.ConsensusAuthorizer(inner) { + t.Fatal("audit mode did not install the observer") + } +} + +// TestWrapAuthzMode_EnforceRefusesToBoot — enforcement is a later gate. +// Setting it must stop the process, not switch on denial. +func TestWrapAuthzMode_EnforceRefusesToBoot(t *testing.T) { + t.Setenv(zapserver.EnvAuthzMode, "enforce") + if _, err := wrapAuthzMode(&noopAuthorizer{}, nil); err == nil { + t.Fatal("KMS_AUTHZ_MODE=enforce must refuse to boot in this build") + } +} + +// TestWrapAuthzMode_TypoRefusesToBoot — a misspelled mode must not +// silently degrade to "off". +func TestWrapAuthzMode_TypoRefusesToBoot(t *testing.T) { + t.Setenv(zapserver.EnvAuthzMode, "audti") + if _, err := wrapAuthzMode(&noopAuthorizer{}, nil); err == nil { + t.Fatal("a misspelled KMS_AUTHZ_MODE must refuse to boot") + } +} + +// TestConsensusSnapshot_WireShapeMatchesOperator pins the cross-repo +// contract against the exact bytes hanzoai/kms-operator's +// bootstrap.Snapshot.MarshalCanonical produces. +func TestConsensusSnapshot_WireShapeMatchesOperator(t *testing.T) { + const operatorBytes = `{"validators":["` + nodeA + `"],"operators":["` + nodeB + `"],` + + `"scopes":{"identities":{"` + nodeB + `":{"unconfined":true,"grants":[]},` + + `"` + nodeA + `":{"grants":[{"org":"hanzo","env":"prod","path":"llm-secrets"}]}}}}` + + var snap consensusSnapshot + if err := json.Unmarshal([]byte(operatorBytes), &snap); err != nil { + t.Fatalf("operator wire form did not decode: %v", err) + } + if snap.Scopes == nil || len(snap.Scopes.Identities) != 2 { + t.Fatalf("overlay misread: %+v", snap.Scopes) + } + if g := snap.Scopes.Identities[nodeA].Grants; len(g) != 1 || g[0].Path != "llm-secrets" { + t.Fatalf("grant misaligned: %+v", g) + } +} + +// noopAuthorizer is a distinct type so pointer identity is meaningful. +type noopAuthorizer struct{} + +func (n *noopAuthorizer) Authorize(_ context.Context, _ zapserver.Identity, _ string, _ zapserver.Op) (zapserver.Decision, error) { + return zapserver.Allow("test"), nil +} diff --git a/pkg/zapserver/authz_mode.go b/pkg/zapserver/authz_mode.go new file mode 100644 index 000000000..35d0c903f --- /dev/null +++ b/pkg/zapserver/authz_mode.go @@ -0,0 +1,292 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// authz_mode.go — the scope overlay's OBSERVATION stage. +// +// Consensus membership answers "who are you". A scope grant answers "what +// may you address". Those are orthogonal, and this file adds only the +// second one's *observation*, never its enforcement. +// +// # Why observation comes first, as its own gate +// +// Turning path scoping on in one step is a total KMS lockout. Measured +// against the live fleet, 127 of 129 KMSSecret consumers read a path that +// shares no prefix with the service path their identity is derived from. +// Any authorizer that starts denying on a scope mismatch denies almost +// every real request on its first tick. There is no safe way to discover +// that from a changelog — it has to be MEASURED against production +// traffic before anything denies. That is what audit mode is for. +// +// # The three states, and the one that is the default +// +// unset / "" → AuthzModeOff. No scope evaluation happens at all. +// WrapScopeAuthorizer returns the inner authorizer +// UNWRAPPED, so there is not one extra branch on the +// request path. Byte-identical to a build without this +// file. +// "audit" → AuthzModeAudit. Every request is evaluated against the +// scope overlay and every would-deny is LOGGED. The +// decision returned to the caller is the inner +// authorizer's, unchanged. Nothing is denied that would +// not already have been denied. +// "enforce" → REJECTED with an error. Enforcement is a later gate and +// deliberately has no code path here: a typo in a +// Deployment env var must not be able to switch on +// denial. +// +// Any other value is a hard error, so a misspelled mode fails loudly at +// boot instead of silently degrading to "off". +// +// # What audit mode can and cannot see +// +// A Grant carries three dimensions (org, env, path) because all three are +// real. The ZAP authorizer contract carries ONE: the opcode's `path`. +// Env is a genuine dimension of the store key +// (kms/secrets/{path}/{env}/{name}) that verifyAndAuthorize does not +// forward; org is a REST-plane concept the ZAP store does not model at +// all. So a scope match here is a PATH match, and it is therefore +// OVER-permissive: audit UNDER-reports the denials real enforcement would +// produce. Every audit line says so explicitly (`dimensions=path-only`) +// and the boot line warns once. Widening the contract to the full +// resource address is a prerequisite for enforcement, not for this gate. + +package zapserver + +import ( + "context" + "fmt" + "strings" + + "github.com/luxfi/ids" + "github.com/luxfi/log" +) + +// AuthzMode selects how scope decisions are applied. The zero value is +// AuthzModeOff, which is what an unset env var resolves to. +type AuthzMode uint8 + +const ( + // AuthzModeOff performs no scope evaluation. The default. + AuthzModeOff AuthzMode = iota + + // AuthzModeAudit evaluates scopes and logs would-denies without + // changing any decision. + AuthzModeAudit +) + +// EnvAuthzMode is the environment variable that selects the mode. +const EnvAuthzMode = "KMS_AUTHZ_MODE" + +// String returns the canonical wire spelling. +func (m AuthzMode) String() string { + switch m { + case AuthzModeOff: + return "off" + case AuthzModeAudit: + return "audit" + } + return fmt.Sprintf("AuthzMode(%d)", uint8(m)) +} + +// ParseAuthzMode resolves the KMS_AUTHZ_MODE value. Pure function. +// +// ""/"off" → AuthzModeOff (today's behaviour, exactly) +// "audit" → AuthzModeAudit +// "enforce"→ error: enforcement is a later gate with no code path here +// anything else → error +func ParseAuthzMode(raw string) (AuthzMode, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "off": + return AuthzModeOff, nil + case "audit": + return AuthzModeAudit, nil + case "enforce": + return AuthzModeOff, fmt.Errorf( + "%s=enforce: scope enforcement is not implemented in this build; "+ + "run %s=audit and confirm the would-deny stream is empty first", EnvAuthzMode, EnvAuthzMode) + default: + return AuthzModeOff, fmt.Errorf("%s=%q: unknown mode (want \"\", \"off\", or \"audit\")", EnvAuthzMode, raw) + } +} + +// Grant is one exact secret address an identity may reach. Wire-identical +// to the kms-operator's bootstrap.Grant — this is the consuming half of +// that contract, pinned by a shared golden fixture in the tests. +type Grant struct { + Org string `json:"org"` + Env string `json:"env"` + Path string `json:"path"` +} + +// Grants is the scope SET for one identity. +// +// Unconfined is an EXPLICIT flag, never inferred from an empty set: an +// identity with zero grants is granted nothing. "Empty means everything" +// is the classic fail-open reading and this shape makes it +// unrepresentable. +type Grants struct { + Unconfined bool `json:"unconfined,omitempty"` + Grants []Grant `json:"grants"` +} + +// ScopeProvider returns the grant set consensus currently attests for a +// NodeID. The bool reports whether the identity has an ENTRY at all — an +// absent entry is not the same as an empty one, and only an explicit +// empty set means "granted nothing". +type ScopeProvider interface { + GrantsFor(ctx context.Context, node ids.NodeID) (Grants, bool) +} + +// StaticScopeProvider serves a snapshot handed over at boot — the same +// consensus-snapshot delivery the authority sets use. Not an ACL: the +// snapshot's authority is "consensus said these grants held at time T". +type StaticScopeProvider struct { + byNode map[ids.NodeID]Grants +} + +// NewStaticScopeProvider copies the supplied map so later mutation by the +// caller cannot change what the server enforces. +func NewStaticScopeProvider(m map[ids.NodeID]Grants) *StaticScopeProvider { + c := make(map[ids.NodeID]Grants, len(m)) + for k, v := range m { + c[k] = v + } + return &StaticScopeProvider{byNode: c} +} + +// GrantsFor implements ScopeProvider. +func (s *StaticScopeProvider) GrantsFor(_ context.Context, node ids.NodeID) (Grants, bool) { + g, ok := s.byNode[node] + return g, ok +} + +// Len reports the number of identities carrying a grant entry. Boot-log +// only. +func (s *StaticScopeProvider) Len() int { return len(s.byNode) } + +// ScopeAuthorizerConfig wires the observation stage. +type ScopeAuthorizerConfig struct { + // Mode selects off / audit. Required — AuthzModeOff means the + // wrapper is not installed at all. + Mode AuthzMode + + // Scopes supplies the grant sets. Required in audit mode. + Scopes ScopeProvider + + // Logger receives the would-deny stream. nil → log.Root(). + Logger log.Logger +} + +// WrapScopeAuthorizer returns the authorizer the kmsd should install. +// +// In AuthzModeOff it returns `inner` VERBATIM. Not a pass-through +// wrapper — the actual inner value, so an unset KMS_AUTHZ_MODE leaves +// the request path with the same call graph, the same allocations and the +// same decisions as a build that never had this file. +func WrapScopeAuthorizer(inner ConsensusAuthorizer, cfg ScopeAuthorizerConfig) (ConsensusAuthorizer, error) { + if inner == nil { + return nil, fmt.Errorf("zapserver: inner authorizer is required") + } + if cfg.Mode == AuthzModeOff { + return inner, nil + } + if cfg.Scopes == nil { + return nil, fmt.Errorf("zapserver: %s=%s requires a scope provider", EnvAuthzMode, cfg.Mode) + } + if cfg.Logger == nil { + cfg.Logger = log.Root() + } + return &ScopeAuthorizer{inner: inner, mode: cfg.Mode, scopes: cfg.Scopes, log: cfg.Logger}, nil +} + +// ScopeAuthorizer decorates a ConsensusAuthorizer with scope observation. +// Installed only in audit mode. +type ScopeAuthorizer struct { + inner ConsensusAuthorizer + mode AuthzMode + scopes ScopeProvider + log log.Logger +} + +// Authorize delegates to the inner authorizer and returns its decision +// UNCHANGED. In audit mode it additionally evaluates the scope overlay +// and logs any would-deny. +// +// Ordering matters: the scope check runs only on requests the inner +// authorizer ALLOWED. A request the role model already denies tells us +// nothing about scoping, and logging it would bury the signal that +// matters — the requests that pass today and would stop passing under +// enforcement. +func (s *ScopeAuthorizer) Authorize(ctx context.Context, ident Identity, path string, op Op) (Decision, error) { + decision, err := s.inner.Authorize(ctx, ident, path, op) + if err != nil || !decision.Allow { + return decision, err + } + + if reason, would := s.wouldDeny(ctx, ident, path); would { + s.log.Warn("kms.authz would-deny", + "mode", s.mode.String(), + "enforced", false, + "ident", ident.String(), + "node", ident.NodeID.String(), + "op", op.String(), + "path", canonicalScopePath(path), + "reason", reason, + // The audit preview compares PATH only: the authorizer + // contract carries neither env nor org, so real enforcement + // would deny at least this set and probably more. + "dimensions", "path-only", + ) + } + + // Audit changes nothing. The inner decision is returned verbatim. + return decision, err +} + +// wouldDeny reports whether scope enforcement would reject this request, +// with a structured reason. Never mutates state. +func (s *ScopeAuthorizer) wouldDeny(ctx context.Context, ident Identity, path string) (string, bool) { + grants, ok := s.scopes.GrantsFor(ctx, ident.NodeID) + if !ok { + // No entry at all. Under enforcement this is fail-closed: an + // identity consensus has said nothing about is granted nothing. + return "no-scope-entry", true + } + if grants.Unconfined { + return "", false + } + want := canonicalScopePath(path) + for _, g := range grants.Grants { + if pathWithinGrant(want, canonicalScopePath(g.Path)) { + return "", false + } + } + if len(grants.Grants) == 0 { + return "scoped-zero-grants", true + } + return "path-outside-grant-set", true +} + +// pathWithinGrant reports whether `want` is the granted path or a +// descendant of it. An empty grant path is the org root and covers +// everything beneath it — that is a real, live grant shape +// (cloud-widget-kms-sync holds "/"), not a wildcard bug. +// +// Segment-boundary aware on purpose: "bootnode-secrets" must NOT match a +// grant on "bootnode". Prefix matching without the boundary check is how +// a sibling path gets silently authorized. +func pathWithinGrant(want, grant string) bool { + if grant == "" { + return true + } + if want == grant { + return true + } + return strings.HasPrefix(want, grant+"/") +} + +// canonicalScopePath trims whitespace and surrounding slashes so the +// operator's emitted form and the wire's requested form compare equal. +func canonicalScopePath(p string) string { + return strings.Trim(strings.TrimSpace(p), "/") +} diff --git a/pkg/zapserver/authz_mode_test.go b/pkg/zapserver/authz_mode_test.go new file mode 100644 index 000000000..1bdca8ba8 --- /dev/null +++ b/pkg/zapserver/authz_mode_test.go @@ -0,0 +1,423 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// authz_mode_test.go — the three properties this gate must hold: +// +// (a) unset mode is UNCHANGED — not "equivalent", literally the same +// authorizer value, so the request path cannot have drifted; +// (b) audit mode denies NOTHING while logging every would-deny; +// (c) the grant SET is honoured — a many-to-many identity matches on +// every path it holds, and a sibling path is not silently allowed. + +package zapserver + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/luxfi/ids" + "github.com/luxfi/log" +) + +// fakeAuthorizer records what it was asked and returns a fixed decision. +type fakeAuthorizer struct { + decision Decision + err error + + mu sync.Mutex + calls int +} + +func (f *fakeAuthorizer) Authorize(context.Context, Identity, string, Op) (Decision, error) { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + return f.decision, f.err +} + +// capturingLogger records Warn lines so the test can assert the +// would-deny stream without parsing stderr. +type capturingLogger struct { + log.Logger + mu sync.Mutex + warns []string +} + +func (c *capturingLogger) Warn(msg string, ctx ...interface{}) { + c.mu.Lock() + defer c.mu.Unlock() + b := strings.Builder{} + b.WriteString(msg) + for i := 0; i+1 < len(ctx); i += 2 { + b.WriteString(" ") + b.WriteString(toStr(ctx[i])) + b.WriteString("=") + b.WriteString(toStr(ctx[i+1])) + } + c.warns = append(c.warns, b.String()) +} + +func (c *capturingLogger) lines() []string { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]string, len(c.warns)) + copy(out, c.warns) + return out +} + +func toStr(v interface{}) string { + switch t := v.(type) { + case string: + return t + case bool: + if t { + return "true" + } + return "false" + default: + b, _ := json.Marshal(v) + return string(b) + } +} + +func newCapturingLogger() *capturingLogger { + return &capturingLogger{Logger: log.NewNoOpLogger()} +} + +func testNodeID(t *testing.T, seed byte) ids.NodeID { + t.Helper() + var n ids.NodeID + for i := range n { + n[i] = seed + } + return n +} + +// --- (a) unset mode is byte-identical --------------------------------- + +func TestParseAuthzMode(t *testing.T) { + for _, c := range []struct { + in string + want AuthzMode + wantErr bool + }{ + {"", AuthzModeOff, false}, + {"off", AuthzModeOff, false}, + {"OFF", AuthzModeOff, false}, + {" ", AuthzModeOff, false}, + {"audit", AuthzModeAudit, false}, + {" Audit ", AuthzModeAudit, false}, + {"enforce", AuthzModeOff, true}, + {"ENFORCE", AuthzModeOff, true}, + {"enforcee", AuthzModeOff, true}, + {"true", AuthzModeOff, true}, + } { + got, err := ParseAuthzMode(c.in) + if (err != nil) != c.wantErr { + t.Errorf("ParseAuthzMode(%q) err = %v, wantErr %v", c.in, err, c.wantErr) + } + if got != c.want { + t.Errorf("ParseAuthzMode(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// TestParseAuthzMode_EnforceIsNotAModeInThisBuild — enforcement is a +// later gate. A Deployment that sets it must fail loudly at boot rather +// than start denying. +func TestParseAuthzMode_EnforceIsNotAModeInThisBuild(t *testing.T) { + _, err := ParseAuthzMode("enforce") + if err == nil { + t.Fatal("enforce must be rejected — it has no code path in this build") + } + if !strings.Contains(err.Error(), "not implemented") { + t.Fatalf("error must say enforcement is unimplemented, got: %v", err) + } +} + +// TestWrapScopeAuthorizer_UnsetReturnsInnerVerbatim is the strongest form +// of "unchanged": the returned value IS the inner authorizer, so an unset +// KMS_AUTHZ_MODE cannot have altered a single decision, allocation, or +// branch on the request path. +func TestWrapScopeAuthorizer_UnsetReturnsInnerVerbatim(t *testing.T) { + inner := &fakeAuthorizer{decision: Allow("validator-read")} + got, err := WrapScopeAuthorizer(inner, ScopeAuthorizerConfig{Mode: AuthzModeOff}) + if err != nil { + t.Fatalf("WrapScopeAuthorizer: %v", err) + } + if got != ConsensusAuthorizer(inner) { + t.Fatalf("off mode returned a wrapper (%T), want the inner authorizer verbatim", got) + } + // And it works with no scope provider at all — off mode must not + // require any overlay to exist. + if _, err := WrapScopeAuthorizer(inner, ScopeAuthorizerConfig{Mode: AuthzModeOff, Scopes: nil}); err != nil { + t.Fatalf("off mode must not require a scope provider: %v", err) + } +} + +// TestWrapScopeAuthorizer_AuditRequiresScopes — audit without an overlay +// source is a configuration error, not a silent no-op. +func TestWrapScopeAuthorizer_AuditRequiresScopes(t *testing.T) { + inner := &fakeAuthorizer{decision: Allow("validator-read")} + if _, err := WrapScopeAuthorizer(inner, ScopeAuthorizerConfig{Mode: AuthzModeAudit}); err == nil { + t.Fatal("audit mode without a scope provider must error") + } +} + +// --- (b) audit denies nothing ------------------------------------------ + +// TestAudit_DeniesNothing_ButLogsWouldDenies is the core safety property. +// An identity with NO grant entry — the worst case, the one enforcement +// would reject outright — still gets the inner authorizer's Allow. +func TestAudit_DeniesNothing_ButLogsWouldDenies(t *testing.T) { + node := testNodeID(t, 0x11) + inner := &fakeAuthorizer{decision: Allow("validator-read")} + lg := newCapturingLogger() + + az, err := WrapScopeAuthorizer(inner, ScopeAuthorizerConfig{ + Mode: AuthzModeAudit, + Scopes: NewStaticScopeProvider(map[ids.NodeID]Grants{}), // nobody granted anything + Logger: lg, + }) + if err != nil { + t.Fatalf("WrapScopeAuthorizer: %v", err) + } + + for _, path := range []string{"llm-secrets", "bootnode", "anything/at/all"} { + got, err := az.Authorize(context.Background(), Identity{NodeID: node, ServicePath: "hanzo/enso-secrets"}, path, OpAuthGet) + if err != nil { + t.Fatalf("audit mode returned an error: %v", err) + } + if !got.Allow { + t.Fatalf("audit mode DENIED %q — audit must never change a decision: %+v", path, got) + } + if got.Reason != "validator-read" { + t.Fatalf("audit mode rewrote the reason: %q", got.Reason) + } + } + + lines := lg.lines() + if len(lines) != 3 { + t.Fatalf("expected 3 would-deny lines, got %d: %v", len(lines), lines) + } + for _, l := range lines { + if !strings.Contains(l, "kms.authz would-deny") { + t.Errorf("unexpected log line: %s", l) + } + if !strings.Contains(l, "enforced=false") { + t.Errorf("audit line must state enforced=false: %s", l) + } + if !strings.Contains(l, "reason=no-scope-entry") { + t.Errorf("missing structured reason: %s", l) + } + if !strings.Contains(l, "dimensions=path-only") { + t.Errorf("audit line must disclose that it compares path only: %s", l) + } + } +} + +// TestAudit_DoesNotSecondGuessAnInnerDeny — a request the role model +// already denies carries no scope signal. Logging it would bury the +// signal that matters (requests that pass today and would stop passing). +func TestAudit_DoesNotSecondGuessAnInnerDeny(t *testing.T) { + inner := &fakeAuthorizer{decision: Deny("not-a-validator")} + lg := newCapturingLogger() + az, err := WrapScopeAuthorizer(inner, ScopeAuthorizerConfig{ + Mode: AuthzModeAudit, + Scopes: NewStaticScopeProvider(map[ids.NodeID]Grants{}), + Logger: lg, + }) + if err != nil { + t.Fatalf("WrapScopeAuthorizer: %v", err) + } + got, err := az.Authorize(context.Background(), Identity{NodeID: testNodeID(t, 0x22)}, "x", OpAuthGet) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Allow { + t.Fatal("audit mode must not upgrade an inner Deny to Allow") + } + if got.Reason != "not-a-validator" { + t.Fatalf("inner deny reason rewritten: %q", got.Reason) + } + if n := len(lg.lines()); n != 0 { + t.Fatalf("audit logged %d lines for an already-denied request: %v", n, lg.lines()) + } +} + +// TestAudit_PropagatesInnerError — a provider outage must surface +// unchanged; audit must not swallow it into an Allow. +func TestAudit_PropagatesInnerError(t *testing.T) { + inner := &fakeAuthorizer{decision: Deny("validator-authority-unreachable"), err: context.DeadlineExceeded} + lg := newCapturingLogger() + az, _ := WrapScopeAuthorizer(inner, ScopeAuthorizerConfig{ + Mode: AuthzModeAudit, + Scopes: NewStaticScopeProvider(map[ids.NodeID]Grants{}), + Logger: lg, + }) + got, err := az.Authorize(context.Background(), Identity{NodeID: testNodeID(t, 0x33)}, "x", OpAuthGet) + if err == nil { + t.Fatal("inner error must propagate") + } + if got.Allow { + t.Fatal("audit mode must not turn a fail-closed deny into an allow") + } +} + +// --- (c) the grant SET is honoured ------------------------------------- + +// TestAudit_ManyToManyIdentity_NoWouldDenyOnAnyGrantedPath — the +// hanzo-platform shape. An identity holding many unrelated paths must +// produce ZERO would-denies across all of them. A scalar scope would flag +// all but one, which is exactly the lockout preview this gate exists to +// avoid mispredicting. +func TestAudit_ManyToManyIdentity_NoWouldDenyOnAnyGrantedPath(t *testing.T) { + node := testNodeID(t, 0x44) + paths := []string{ + "admin-guard-secrets", "adnexus", "argocd/repo-ssh", "auth", + "bootnode", "bootnode-secrets", "bot", "bot-browser", + "buildx-ghcr-auth", "chat-guest-key", "platform", "storage", + } + grants := make([]Grant, 0, len(paths)) + for _, p := range paths { + grants = append(grants, Grant{Org: "hanzo", Env: "prod", Path: p}) + } + + lg := newCapturingLogger() + az, err := WrapScopeAuthorizer(&fakeAuthorizer{decision: Allow("validator-read")}, ScopeAuthorizerConfig{ + Mode: AuthzModeAudit, + Scopes: NewStaticScopeProvider(map[ids.NodeID]Grants{node: {Grants: grants}}), + Logger: lg, + }) + if err != nil { + t.Fatalf("WrapScopeAuthorizer: %v", err) + } + + for _, p := range paths { + if _, err := az.Authorize(context.Background(), Identity{NodeID: node}, "/"+p+"/", OpAuthGet); err != nil { + t.Fatalf("%s: %v", p, err) + } + } + if lines := lg.lines(); len(lines) != 0 { + t.Fatalf("granted paths produced %d would-denies (a scalar scope would produce %d): %v", + len(lines), len(paths)-1, lines) + } + + // A path outside the set IS flagged — the overlay is doing real work. + if _, err := az.Authorize(context.Background(), Identity{NodeID: node}, "iam", OpAuthGet); err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := lg.lines() + if len(lines) != 1 || !strings.Contains(lines[0], "reason=path-outside-grant-set") { + t.Fatalf("ungranted path was not flagged: %v", lines) + } +} + +// TestPathWithinGrant_SegmentBoundary — prefix matching without a +// segment-boundary check silently authorizes a SIBLING. Both of these +// are live production paths, so this is not a synthetic edge case. +func TestPathWithinGrant_SegmentBoundary(t *testing.T) { + for _, c := range []struct { + want, grant string + ok bool + }{ + {"bootnode", "bootnode", true}, + {"bootnode/sub", "bootnode", true}, + {"bootnode-secrets", "bootnode", false}, // sibling, NOT a descendant + {"bootnodex", "bootnode", false}, + {"argocd/repo-ssh", "argocd", true}, + {"anything", "", true}, // org root grant covers everything beneath it + {"", "", true}, + {"", "bootnode", false}, + } { + if got := pathWithinGrant(c.want, c.grant); got != c.ok { + t.Errorf("pathWithinGrant(%q, %q) = %v, want %v", c.want, c.grant, got, c.ok) + } + } +} + +// TestAudit_UnconfinedIdentityIsNeverFlagged — the kms-operator writes on +// every service's behalf; confining it would confine everything. +func TestAudit_UnconfinedIdentityIsNeverFlagged(t *testing.T) { + node := testNodeID(t, 0x55) + lg := newCapturingLogger() + az, _ := WrapScopeAuthorizer(&fakeAuthorizer{decision: Allow("operator-write")}, ScopeAuthorizerConfig{ + Mode: AuthzModeAudit, + Scopes: NewStaticScopeProvider(map[ids.NodeID]Grants{node: {Unconfined: true}}), + Logger: lg, + }) + for _, p := range []string{"a", "b/c", ""} { + if _, err := az.Authorize(context.Background(), Identity{NodeID: node}, p, OpAuthPut); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + if lines := lg.lines(); len(lines) != 0 { + t.Fatalf("unconfined identity flagged: %v", lines) + } +} + +// TestAudit_ScopedZeroGrantsIsDenyAll — an EXPLICIT empty grant set must +// read as "granted nothing", never as "granted everything". The +// distinction between this and Unconfined is the whole fail-closed +// design. +func TestAudit_ScopedZeroGrantsIsDenyAll(t *testing.T) { + node := testNodeID(t, 0x66) + lg := newCapturingLogger() + az, _ := WrapScopeAuthorizer(&fakeAuthorizer{decision: Allow("validator-read")}, ScopeAuthorizerConfig{ + Mode: AuthzModeAudit, + Scopes: NewStaticScopeProvider(map[ids.NodeID]Grants{node: {Grants: []Grant{}}}), + Logger: lg, + }) + got, _ := az.Authorize(context.Background(), Identity{NodeID: node}, "anything", OpAuthGet) + if !got.Allow { + t.Fatal("audit must still allow") + } + lines := lg.lines() + if len(lines) != 1 || !strings.Contains(lines[0], "reason=scoped-zero-grants") { + t.Fatalf("empty grant set not reported as deny-all: %v", lines) + } +} + +// TestGrants_WireShapeMatchesOperator pins the cross-repo contract. These +// bytes are exactly what hanzoai/kms-operator's bootstrap.Snapshot +// marshals (see its scopes_test.go golden assertions). If either side +// drifts, this fails rather than silently producing an overlay the other +// side reads as empty — which under enforcement is a lockout. +func TestGrants_WireShapeMatchesOperator(t *testing.T) { + const golden = `{"unconfined":true,"grants":[]}` + var unconfined Grants + if err := json.Unmarshal([]byte(golden), &unconfined); err != nil { + t.Fatalf("unmarshal operator wire form: %v", err) + } + if !unconfined.Unconfined || len(unconfined.Grants) != 0 { + t.Fatalf("operator unconfined form misread: %+v", unconfined) + } + + const goldenScoped = `{"grants":[{"org":"hanzo","env":"prod","path":"bootnode"},{"org":"hanzo","env":"prod","path":"bootnode-secrets"}]}` + var scoped Grants + if err := json.Unmarshal([]byte(goldenScoped), &scoped); err != nil { + t.Fatalf("unmarshal operator scoped form: %v", err) + } + if scoped.Unconfined { + t.Fatal("scoped form must not decode as unconfined") + } + if len(scoped.Grants) != 2 { + t.Fatalf("grant set lost entries: %+v", scoped.Grants) + } + if scoped.Grants[0] != (Grant{Org: "hanzo", Env: "prod", Path: "bootnode"}) { + t.Fatalf("grant fields misaligned: %+v", scoped.Grants[0]) + } + + // The deny-all form must NOT decode as unconfined. + const goldenEmpty = `{"grants":[]}` + var empty Grants + if err := json.Unmarshal([]byte(goldenEmpty), &empty); err != nil { + t.Fatalf("unmarshal empty: %v", err) + } + if empty.Unconfined { + t.Fatal("deny-all form decoded as unconfined — fail-open") + } +}