mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
chore: sync working tree
Commits 4 outstanding change(s) that were sitting uncommitted. No build artifacts and no secrets in the changeset (both checked).
This commit is contained in:
+86
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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), "/")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user