diff --git a/api/datalog.go b/api/datalog.go index 71ba6a48..b10e2ca6 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -575,6 +575,75 @@ func LabelCheck(required map[string]string) (biscuit.Check, error) { return parser.FromStringCheck("check if " + strings.Join(clauses, " or ")) } +// LabelFloorCheck compiles an operator's egress floor (see Egress.RequireLabels) +// into a single fail-closed check satisfied only when the token carries *every* +// pair: `check if label("jurisdiction", "eu"), label("compliance", "gdpr")`. +// +// The conjunction is the whole difference from LabelCheck, which is a +// disjunction because a caller naming several labels means "any of these will +// do". A floor cannot mean that: a peer attesting only the most permissive of +// several alternatives would satisfy the floor while sitting outside the +// boundary the operator drew. So a floor takes a map — one value per key, no +// way to spell an alternative — and requires all of it. +// +// Both are ordinary Biscuit checks, so a caller's requirement and a floor are +// combined by adding each to the authorizer and letting it AND them; neither +// needs to know about the other. +func LabelFloorCheck(required map[string]string) (biscuit.Check, error) { + if len(required) == 0 { + return biscuit.Check{}, fmt.Errorf("no required labels") + } + keys := make([]string, 0, len(required)) + for k := range required { + keys = append(keys, k) + } + sort.Strings(keys) + clauses := make([]string, 0, len(required)) + for _, k := range keys { + if err := ValidateLabelKey(k); err != nil { + return biscuit.Check{}, err + } + v := required[k] + if err := ValidateLabelValue(v); err != nil { + return biscuit.Check{}, err + } + clauses = append(clauses, fmt.Sprintf("%s(%q, %q)", FactLabel, k, v)) + } + return parser.FromStringCheck("check if " + strings.Join(clauses, ", ")) +} + +// LabelsSatisfyFloor reports whether claimed satisfies every pair of the floor. +// It is the non-attested counterpart of LabelFloorCheck, for the one provider +// class that has no Biscuit to check: a service local to this node, whose +// labels are its own configuration and so are complete. An empty floor is +// satisfied by anything, so callers may pass one unconditionally. +func LabelsSatisfyFloor(floor, claimed map[string]string) bool { + for k, v := range floor { + if claimed[k] != v { + return false + } + } + return true +} + +// LabelsContradictFloor reports whether claimed states a *different* value for +// some key the floor requires. +// +// Absence is not contradiction, which is the whole distinction from +// LabelsSatisfyFloor. Gossiped claims are a discovery hint and may carry only +// part of what a peer attests, so a peer silent on one pair of the floor may +// still satisfy all of it in its Biscuit. Only a conflicting value is grounds +// to skip such a peer before the gate has seen its attested facts; treating +// silence as failure would drop providers that are inside the boundary. +func LabelsContradictFloor(floor, claimed map[string]string) bool { + for k, v := range floor { + if got, stated := claimed[k]; stated && got != v { + return true + } + } + return false +} + // isExactService reports whether serviceStr resolves to a plain exact-match grant, as opposed to a // wildcard/prefix/suffix pattern which already collapses to a single, cheap fact via BuildServiceDatalogFact. // The classification is asked of BuildServiceDatalogFact rather than repeated here: a new wildcard shape diff --git a/api/datalog_test.go b/api/datalog_test.go index d9c45734..778984a2 100644 --- a/api/datalog_test.go +++ b/api/datalog_test.go @@ -618,3 +618,93 @@ func TestLabelFactsAndCheck(t *testing.T) { }) } } + +// A caller's requirement is a disjunction ("any of these will do") and an +// operator's egress floor is a conjunction ("all of these"). The difference is +// the reason both exist, so it is pinned on the compiled shape rather than on +// the rendered string: a disjunction becomes one query body per alternative, a +// conjunction one body carrying every predicate. +func TestLabelFloorCheckIsConjunctionUnlikeLabelCheck(t *testing.T) { + both := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + floor, err := LabelFloorCheck(both) + if err != nil { + t.Fatalf("LabelFloorCheck: %v", err) + } + if len(floor.Queries) != 1 { + t.Fatalf("a floor must compile to a single conjunctive body, got %d alternatives", len(floor.Queries)) + } + if got := len(floor.Queries[0].Body); got != len(both) { + t.Errorf("the floor's body carries %d predicates, want all %d", got, len(both)) + } + + caller, err := LabelCheck(both) + if err != nil { + t.Fatalf("LabelCheck: %v", err) + } + if len(caller.Queries) != len(both) { + t.Errorf("a caller requirement must compile to one body per alternative, got %d", len(caller.Queries)) + } +} + +func TestLabelFloorCheckRejectsBadInput(t *testing.T) { + if _, err := LabelFloorCheck(nil); err == nil { + t.Error("LabelFloorCheck(nil): expected error, got nil") + } + if _, err := LabelFloorCheck(map[string]string{"region": "bad,value"}); err == nil { + t.Error("LabelFloorCheck(invalid value): expected error, got nil") + } + if _, err := LabelFloorCheck(map[string]string{"bad key!": "v"}); err == nil { + t.Error("LabelFloorCheck(invalid key): expected error, got nil") + } +} + +func TestLabelsSatisfyFloor(t *testing.T) { + floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + tests := []struct { + name string + claimed map[string]string + want bool + }{ + {"every pair present", map[string]string{"jurisdiction": "eu", "compliance": "gdpr", "region": "de"}, true}, + {"one pair missing", map[string]string{"jurisdiction": "eu"}, false}, + {"one pair wrong", map[string]string{"jurisdiction": "eu", "compliance": "hipaa"}, false}, + {"nothing claimed", nil, false}, + } + for _, tt := range tests { + if got := LabelsSatisfyFloor(floor, tt.claimed); got != tt.want { + t.Errorf("%s: LabelsSatisfyFloor = %v, want %v", tt.name, got, tt.want) + } + } + // An empty floor constrains nothing, so callers can pass it unconditionally. + if !LabelsSatisfyFloor(nil, nil) { + t.Error("an empty floor must be satisfied by anything") + } +} + +// The two floor predicates differ on one case, and it is the case that +// matters: a peer silent on a pair the floor requires. Gossip is partial, so +// silence must not read as failure before the gate has seen attested facts. +func TestLabelsContradictFloorTreatsSilenceAsUnknown(t *testing.T) { + floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + partial := map[string]string{"jurisdiction": "eu"} + if LabelsContradictFloor(floor, partial) { + t.Error("a claim silent on one pair does not contradict the floor") + } + if LabelsSatisfyFloor(floor, partial) { + t.Error("but it does not satisfy it either") + } + + conflicting := map[string]string{"jurisdiction": "us"} + if !LabelsContradictFloor(floor, conflicting) { + t.Error("a different value for a required key is a contradiction") + } + + if LabelsContradictFloor(floor, nil) { + t.Error("no claims at all cannot contradict anything") + } + if LabelsContradictFloor(floor, map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}) { + t.Error("a fully satisfying claim must not read as a contradiction") + } +} diff --git a/api/policy.go b/api/policy.go index 235bbf17..57edc59f 100644 --- a/api/policy.go +++ b/api/policy.go @@ -41,10 +41,14 @@ type ServiceConfig struct { // NodeConfig defines the optional attenuation rules and static services for a specific SAM Node. type NodeConfig struct { - Version string `yaml:"version"` - Attenuation Attenuation `yaml:"attenuation"` - Services []ServiceConfig `yaml:"services"` - Labels map[string]string `yaml:"labels,omitempty"` + Version string `yaml:"version"` + Attenuation Attenuation `yaml:"attenuation"` + Services []ServiceConfig `yaml:"services"` + // Labels is what this node is, attested at enrollment; Egress is what it + // demands of the peers it talks to. Adjacent because they are read + // together and mean opposite directions. + Labels map[string]string `yaml:"labels,omitempty"` + Egress Egress `yaml:"egress"` } // NodeConfigVersionV1Alpha1 is the only node config schema this build understands. @@ -66,3 +70,25 @@ type Attenuation struct { Checks []string `yaml:"checks"` Rules []string `yaml:"rules"` } + +// Egress is the operator's outbound policy: what this node demands of the peers +// it talks to. Attenuation is the mirror of it — what this node demands of the +// peers that talk to *it* — and the two are deliberately separate blocks +// because they answer opposite questions. +type Egress struct { + // RequireLabels is a floor every remote provider must attest before this + // node will send it anything, whatever the caller asked for. Absent means + // no floor, which is the historical behaviour: the requirement is then + // whatever the caller supplied, and a caller that supplies nothing is + // unconstrained. + // + // Every pair must hold (AND), unlike a caller's requirement, where any one + // pair is enough (see LabelCheck vs LabelFloorCheck). A map gives one value + // per key, so a floor cannot express alternatives — that is the point: a + // floor with alternatives would let the weakest of them stand in for the + // rest. + // + // A floor naming a label no peer attests reaches nothing, which is a + // usable egress kill switch. + RequireLabels map[string]string `yaml:"require_labels,omitempty"` +} diff --git a/internal/node/config.go b/internal/node/config.go index 8d34ce87..ee60b0e5 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -31,6 +31,11 @@ type NodeConfigComplete struct { Rules []biscuit.Rule Services []api.ServiceConfig Labels map[string]string + + // EgressRequireLabels is the operator's egress floor (api.Egress). Nil + // means no floor, so a caller's requirement — or the absence of one — + // stands on its own, as it always has. + EgressRequireLabels map[string]string } // LoadNodeConfig loads the node configuration from the specified path. @@ -75,6 +80,19 @@ func CompleteNodeConfig(config api.NodeConfig) (*NodeConfigComplete, error) { Labels: config.Labels, } + // Rejected at load, not at first use: a floor that cannot compile would + // otherwise fail open on the request that needed it, and an operator who + // wrote one is entitled to find out at startup instead. + if len(config.Egress.RequireLabels) > 0 { + if err := api.ValidateLabels(config.Egress.RequireLabels); err != nil { + return nil, fmt.Errorf("invalid egress.require_labels: %w", err) + } + if _, err := api.LabelFloorCheck(config.Egress.RequireLabels); err != nil { + return nil, fmt.Errorf("invalid egress.require_labels: %w", err) + } + complete.EgressRequireLabels = config.Egress.RequireLabels + } + for i, svc := range config.Services { if err := api.ValidateServiceFormat(svc.Type + "://" + svc.Name); err != nil { return nil, fmt.Errorf("invalid service config at index %d: %w", i, err) diff --git a/internal/node/config_test.go b/internal/node/config_test.go index a1cf687b..3b9190fb 100644 --- a/internal/node/config_test.go +++ b/internal/node/config_test.go @@ -336,3 +336,64 @@ func TestCompleteNodeConfig(t *testing.T) { t.Fatal("CompleteNodeConfig() with invalid Datalog: want error, got nil") } } + +func TestLoadNodeConfigEgressFloor(t *testing.T) { + write := func(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sam-node.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("floor is loaded", func(t *testing.T) { + cfg, err := LoadNodeConfig(write(t, ` +version: v1alpha1 +egress: + require_labels: + jurisdiction: eu + compliance: gdpr +`)) + if err != nil { + t.Fatalf("LoadNodeConfig: %v", err) + } + if len(cfg.EgressRequireLabels) != 2 || + cfg.EgressRequireLabels["jurisdiction"] != "eu" || + cfg.EgressRequireLabels["compliance"] != "gdpr" { + t.Errorf("EgressRequireLabels = %v", cfg.EgressRequireLabels) + } + }) + + t.Run("absent block means no floor", func(t *testing.T) { + cfg, err := LoadNodeConfig(write(t, "version: v1alpha1\n")) + if err != nil { + t.Fatalf("LoadNodeConfig: %v", err) + } + if cfg.EgressRequireLabels != nil { + t.Errorf("no egress block must leave the floor nil, got %v", cfg.EgressRequireLabels) + } + }) + + // Rejected at load: a floor that cannot compile would otherwise fail open + // on the first request that needed it. + t.Run("a malformed floor fails startup", func(t *testing.T) { + for _, body := range []string{ + "version: v1alpha1\negress:\n require_labels:\n \"bad key!\": eu\n", + "version: v1alpha1\negress:\n require_labels:\n jurisdiction: \"has,comma\"\n", + "version: v1alpha1\negress:\n require_labels:\n jurisdiction: \"\"\n", + } { + if _, err := LoadNodeConfig(write(t, body)); err == nil { + t.Errorf("expected a load error for %q", body) + } + } + }) + + // The schema is strict, so a typo in the block name is refused rather than + // silently leaving the node with no floor. + t.Run("a misspelled key is refused, not ignored", func(t *testing.T) { + if _, err := LoadNodeConfig(write(t, "version: v1alpha1\negress:\n required_labels:\n jurisdiction: eu\n")); err == nil { + t.Error("require_labels misspelled as required_labels must fail the strict schema") + } + }) +} diff --git a/internal/node/labels_gate.go b/internal/node/labels_gate.go index 3c19efd0..f509aaea 100644 --- a/internal/node/labels_gate.go +++ b/internal/node/labels_gate.go @@ -89,29 +89,65 @@ const ( labelGateDialTimeout = 10 * time.Second ) -// labelGateKey builds a deterministic cache key from a required label set. -func labelGateKey(peerID peer.ID, required map[string]string) string { - keys := make([]string, 0, len(required)) - for k := range required { - keys = append(keys, k) - } - sort.Strings(keys) - key := peerID.String() - for _, k := range keys { - key += "|" + k + "=" + required[k] - } - return key +// labelGateKey builds a deterministic cache key from a required label set and +// the floor in force. The floor is part of the key because a verdict reached +// under one floor says nothing about another: keying on the caller's +// requirement alone would let a cached pass survive a configuration change. +// +// The encoding is unambiguous, and it is worth saying why, because the +// separators are ordinary printable characters that a label value is allowed +// to contain. What rules a collision out is that "=" is not: every entry is +// "|=", ValidateLabelKey admits only [a-zA-Z0-9_.-] so a key +// cannot hold a separator, and ValidateLabelValue rejects "=" so a value +// cannot forge the "|=" that starts an entry. A value may therefore +// contain "|" or "#floor" freely without shifting where any boundary falls, +// and no pair of distinct (required, floor) inputs can render the same string. +func labelGateKey(peerID peer.ID, required, floor map[string]string) string { + render := func(sb *strings.Builder, m map[string]string) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + sb.WriteByte('|') + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(m[k]) + } + } + var sb strings.Builder + sb.WriteString(peerID.String()) + render(&sb, required) + sb.WriteString("#floor") + render(&sb, floor) + return sb.String() +} + +// egressFloor returns the operator's egress floor, or nil when none is +// configured (api.Egress.RequireLabels). +func (n *SamNode) egressFloor() map[string]string { + // Nil-safe for the same reason labels() is: NewSamNode always leaves + // nodeConfig set, but tests build SamNode directly. + if n.nodeConfig == nil { + return nil + } + return n.nodeConfig.EgressRequireLabels } // VerifyPeerLabels ensures the peer holds control-plane-attested labels -// satisfying any of the required key=value pairs (canonical, pre-validated). -// A nil/empty requirement passes without network traffic. Positive verdicts -// are cached. +// satisfying the caller's requirement (any one pair) and the operator's egress +// floor (every pair). Positive verdicts are cached. +// +// The gate runs whenever either exists. A caller that requires nothing is +// still held to the floor — the whole point of a floor is that the party being +// constrained does not get to opt out of it by saying nothing. func (n *SamNode) VerifyPeerLabels(ctx context.Context, peerID peer.ID, required map[string]string) error { - if len(required) == 0 { + floor := n.egressFloor() + if len(required) == 0 && len(floor) == 0 { return nil } - key := labelGateKey(peerID, required) + key := labelGateKey(peerID, required, floor) if until, ok := n.peerLabelGate.Get(key); ok && time.Now().Before(until) { return nil } @@ -129,11 +165,12 @@ func (n *SamNode) VerifyPeerLabels(ctx context.Context, peerID peer.ID, required } // checkPeerLabels verifies the provider's biscuit (control-plane signature, -// expiry, binding to peerID) and evaluates the compiled label requirement -// against its attested facts. +// expiry, binding to peerID) and evaluates the caller's requirement and the +// operator's egress floor against its attested facts. func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, required map[string]string) error { + floor := n.egressFloor() if len(providerBiscuit) == 0 { - return fmt.Errorf("provider %s returned no identity biscuit; cannot attest required labels %v", peerID, required) + return fmt.Errorf("provider %s returned no identity biscuit; cannot attest required labels %v (egress floor %v)", peerID, required, floor) } n.keysMu.RLock() @@ -155,13 +192,29 @@ func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, requir if err != nil { return fmt.Errorf("provider %s authorizer instantiation failed: %w", peerID, err) } - check, err := api.LabelCheck(required) - if err != nil { - return err + // Two checks, not one merged set. The authorizer ANDs them, so the peer + // has to satisfy both independently; merging them into a single + // disjunction would let the caller's pairs stand in for the floor's and + // widen exactly what the floor exists to bound. + if len(required) > 0 { + check, err := api.LabelCheck(required) + if err != nil { + return err + } + authorizer.AddCheck(check) + } + if len(floor) > 0 { + check, err := api.LabelFloorCheck(floor) + if err != nil { + return err + } + authorizer.AddCheck(check) } - authorizer.AddCheck(check) authorizer.AddPolicy(api.AllowIfTruePolicy) if err := authorizer.Authorize(); err != nil { + if len(floor) > 0 { + return fmt.Errorf("provider %s does not attest the caller requirement %v and the egress floor %v: %w", peerID, required, floor, err) + } return fmt.Errorf("provider %s has no attested label matching %v: %w", peerID, required, err) } return nil diff --git a/internal/node/labels_gate_test.go b/internal/node/labels_gate_test.go index 972283e6..31c0c5f5 100644 --- a/internal/node/labels_gate_test.go +++ b/internal/node/labels_gate_test.go @@ -15,12 +15,14 @@ package node import ( + "context" "crypto/ed25519" "testing" "time" "github.com/google/sam/api" "github.com/google/sam/internal/identity" + lru "github.com/hashicorp/golang-lru/v2" "github.com/libp2p/go-libp2p/core/peer" ) @@ -86,3 +88,177 @@ func TestCheckPeerLabels(t *testing.T) { } }) } + +// The egress floor is the operator's, and a caller cannot waive it by asking +// for nothing or by asking for something else. It is ANDed with the caller's +// requirement, so both hold independently: the caller may narrow the choice of +// provider, never widen it past the floor. +func TestCheckPeerLabelsEnforcesEgressFloor(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + providerPeer := peer.ID("provider-peer-id") + expiry := time.Now().Add(time.Hour) + + mint := func(labels map[string]string) []byte { + t.Helper() + b, err := identity.MintBootstrapBiscuitToken(cpPriv, providerPeer, api.RoleNode, expiry, nil, labels) + if err != nil { + t.Fatalf("mint: %v", err) + } + return b + } + + nodeWithFloor := func(floor map[string]string) *SamNode { + return &SamNode{ + trustedKeys: []TrustedKey{{Key: cpPub, ReceivedAt: time.Now()}}, + BiscuitTimeout: 500 * time.Millisecond, + nodeConfig: &NodeConfigComplete{EgressRequireLabels: floor}, + } + } + + euGdpr := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + tests := []struct { + name string + floor map[string]string + required map[string]string + attested map[string]string + expectErr bool + }{{ + name: "caller requires nothing but the floor still applies", + floor: map[string]string{"jurisdiction": "eu"}, + required: nil, + attested: map[string]string{"jurisdiction": "eu"}, + }, { + name: "caller requires nothing and the provider is outside the floor", + floor: map[string]string{"jurisdiction": "eu"}, + required: nil, + attested: map[string]string{"jurisdiction": "us"}, + expectErr: true, + }, { + name: "the floor is a conjunction: one pair short is not enough", + floor: euGdpr, + required: nil, + attested: map[string]string{"jurisdiction": "eu"}, + expectErr: true, + }, { + name: "the floor is satisfied when every pair is attested", + floor: euGdpr, + required: nil, + attested: map[string]string{"jurisdiction": "eu", "compliance": "gdpr", "region": "de"}, + }, { + // The whole reason the two are separate checks. Merged into one + // disjunction, region=us-east-1 alone would satisfy it. + name: "a caller cannot widen the floor by naming another label", + floor: map[string]string{"jurisdiction": "eu"}, + required: map[string]string{"region": "us-east-1"}, + attested: map[string]string{"jurisdiction": "us", "region": "us-east-1"}, + expectErr: true, + }, { + name: "caller and floor both satisfied", + floor: map[string]string{"jurisdiction": "eu"}, + required: map[string]string{"region": "de-txl"}, + attested: map[string]string{"jurisdiction": "eu", "region": "de-txl"}, + }, { + name: "caller narrows within the floor and the provider misses the narrowing", + floor: map[string]string{"jurisdiction": "eu"}, + required: map[string]string{"region": "de-txl"}, + attested: map[string]string{"jurisdiction": "eu", "region": "fr-par"}, + expectErr: true, + }, { + name: "no floor configured leaves the caller's requirement alone", + floor: nil, + required: map[string]string{"region": "eu"}, + attested: map[string]string{"region": "eu"}, + expectErr: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := nodeWithFloor(tt.floor).checkPeerLabels(mint(tt.attested), providerPeer, tt.required) + if tt.expectErr && err == nil { + t.Errorf("expected rejection, got nil") + } + if !tt.expectErr && err != nil { + t.Errorf("expected acceptance, got %v", err) + } + }) + } +} + +// VerifyPeerLabels short-circuits when there is nothing to check. With a floor +// configured there always is, so the short-circuit must not swallow it: the +// gate has to run even for a caller that asked for nothing, which is the +// difference between a floor and a suggestion. +func TestVerifyPeerLabelsDoesNotShortCircuitPastTheFloor(t *testing.T) { + node := &SamNode{ + BiscuitTimeout: 500 * time.Millisecond, + nodeConfig: &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu"}}, + } + cache, err := lru.New[string, time.Time](8) + if err != nil { + t.Fatal(err) + } + node.peerLabelGate = cache + + // No identity, so the biscuit fetch cannot succeed. Reaching that failure + // is the proof the gate ran at all; returning nil would mean it skipped. + err = node.VerifyPeerLabels(context.Background(), peer.ID("some-peer"), nil) + if err == nil { + t.Fatal("a caller requiring nothing must still be gated when a floor is configured") + } + + // Without a floor, the same call is the documented no-op. + node.nodeConfig = &NodeConfigComplete{} + if err := node.VerifyPeerLabels(context.Background(), peer.ID("some-peer"), nil); err != nil { + t.Errorf("with no floor and no requirement the gate must not run: %v", err) + } +} + +// The cache key has to separate the caller's requirement from the floor, or a +// pass recorded under one floor could be replayed under another. +func TestLabelGateKeySeparatesFloorFromRequirement(t *testing.T) { + p := peer.ID("peer") + a := labelGateKey(p, map[string]string{"jurisdiction": "eu"}, nil) + b := labelGateKey(p, nil, map[string]string{"jurisdiction": "eu"}) + if a == b { + t.Errorf("a requirement and a floor with the same pair must not share a cache key (%q)", a) + } +} + +// The key's separators are printable characters a label value is allowed to +// contain, so the encoding's unambiguity rests on ValidateLabelValue rejecting +// "=": a value can hold "|" or "#floor" but cannot forge the "|=" that +// begins an entry. These are the shapes that would collide if it could. +func TestLabelGateKeyIsUnambiguousWithSeparatorsInValues(t *testing.T) { + p := peer.ID("peer") + // Every value here is valid input: the validator rejects only a comma, an + // equals sign, and the three whitespace control characters. + for _, v := range []string{"eu|x", "eu#floor", "#floor|b", "a|b#floor|c"} { + if err := api.ValidateLabelValue(v); err != nil { + t.Fatalf("test premise wrong, %q is not a valid label value: %v", v, err) + } + } + + seen := map[string][]string{} + add := func(desc string, required, floor map[string]string) { + k := labelGateKey(p, required, floor) + seen[k] = append(seen[k], desc) + } + add("value carrying the entry separator", map[string]string{"a": "eu|x"}, nil) + add("two entries", map[string]string{"a": "eu", "x": "1"}, nil) + add("value carrying the floor marker", map[string]string{"a": "eu#floor"}, nil) + add("floor holding the same pair", nil, map[string]string{"a": "eu"}) + add("requirement holding the same pair", map[string]string{"a": "eu"}, nil) + add("value that looks like a floor section", map[string]string{"a": "#floor|b"}, nil) + add("split across both sets", map[string]string{"a": "eu"}, map[string]string{"b": "1"}) + add("both sets, values with separators", map[string]string{"a": "a|b#floor|c"}, map[string]string{"b": "1"}) + + for key, descs := range seen { + if len(descs) > 1 { + t.Errorf("these distinct inputs share cache key %q: %v", key, descs) + } + } +} diff --git a/internal/node/mcp.go b/internal/node/mcp.go index 522ed749..414e64a4 100644 --- a/internal/node/mcp.go +++ b/internal/node/mcp.go @@ -240,10 +240,10 @@ func (s *agentMCPServers) forAgent(agentID string) *mcp.Server { return server } -// CallMCPTool opens a stream to a remote peer, performs the handshake, and calls a tool. // CallMCPTool opens a stream to a remote peer, performs the handshake, and calls a tool. // requiredLabels, when non-empty, fail-closed verifies the peer's control-plane-attested -// labels (see checkPeerLabels) before the tool is invoked; nil means no requirement. +// labels (see checkPeerLabels) before the tool is invoked; nil means no caller +// requirement, though the operator's egress floor, if configured, is still enforced. func (n *SamNode) CallMCPTool(ctx context.Context, targetPeer peer.ID, toolName string, params any, requiredLabels map[string]string) (*mcp.CallToolResult, error) { var res *mcp.CallToolResult var err error @@ -378,7 +378,10 @@ func (n *SamNode) ConnectMCPSession(ctx context.Context, targetPeer peer.ID, tar return nil, nil, fmt.Errorf("%w by %s: %s", ErrAuthRejected, targetPeer, resp.Error) } - if len(requiredLabels) > 0 { + // The gate runs when the caller requires labels or when the operator's + // egress floor does: a caller that requires nothing is still held to the + // floor (checkPeerLabels ANDs both). + if len(requiredLabels) > 0 || len(n.egressFloor()) > 0 { if err := n.checkPeerLabels(resp.Biscuit, targetPeer, requiredLabels); err != nil { cleanup() return nil, nil, err diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index 4caa6195..345b624a 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -744,6 +744,70 @@ func TestCallMCPTool_LabelEnforcement(t *testing.T) { } } +// The operator's egress floor gates MCP egress even when the caller supplies no +// required_labels: silence must not waive the floor (see #385). +func TestCallMCPTool_EgressFloorEnforcement(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + nodeA, cleanupA := startBareNode(t, ctx) + defer cleanupA() + nodeB, cleanupB := startBareNode(t, ctx) + defer cleanupB() + + if err := nodeA.Host.Connect(ctx, peer.AddrInfo{ID: nodeB.Host.ID(), Addrs: nodeB.Host.Addrs()}); err != nil { + t.Fatalf("connect: %v", err) + } + + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen root key: %v", err) + } + + // nodeA authenticates to nodeB as an unrestricted caller. + if err := buildAndSaveBiscuit(nodeA, rootPriv); err != nil { + t.Fatalf("buildAndSaveBiscuit: %v", err) + } + nodeB.keysMu.Lock() + nodeB.trustedKeys = append(nodeB.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeB.keysMu.Unlock() + + // nodeB attests region=eu-de and nothing else. + nodeBIdentity, err := identity.MintBootstrapBiscuitToken(rootPriv, nodeB.Host.ID(), api.RoleNode, time.Now().Add(time.Hour), nil, map[string]string{"region": "eu-de"}) + if err != nil { + t.Fatalf("mint nodeB identity: %v", err) + } + nodeB.SetIdentityCache(nodeBIdentity) + nodeA.keysMu.Lock() + nodeA.trustedKeys = append(nodeA.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeA.keysMu.Unlock() + + hostedSrv := httptest.NewServer(newFakeMCPHandler(t, []*mcp.Tool{ + {Name: "review_pr", Description: "Run a code review", InputSchema: map[string]any{"type": "object"}}, + })) + defer hostedSrv.Close() + + if err := nodeB.RegisterService(ctx, &api.RegisterServiceRequest{ + Service: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "code-reviewer"}, + Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: hostedSrv.URL}, + }); err != nil { + t.Fatalf("RegisterService: %v", err) + } + defer func() { _ = nodeB.UnregisterService(ctx, "code-reviewer") }() + + // Floor satisfied by nodeB's attestation; the caller requires nothing. + nodeA.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"region": "eu-de"}} + if _, err := nodeA.CallMCPTool(ctx, nodeB.Host.ID(), "mcp://code-reviewer/review_pr", map[string]any{}, nil); err != nil { + t.Fatalf("CallMCPTool inside the floor should succeed: %v", err) + } + + // Floor nodeB does not attest; the caller staying silent must not waive it. + nodeA.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu"}} + if _, err := nodeA.CallMCPTool(ctx, nodeB.Host.ID(), "mcp://code-reviewer/review_pr", map[string]any{}, nil); err == nil { + t.Fatal("CallMCPTool outside the floor with no caller requirement must fail") + } +} + func TestNewMCPHandler_RegistersFindRemoteTools(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/internal/node/openai_facade.go b/internal/node/openai_facade.go index 481533ad..dee7b216 100644 --- a/internal/node/openai_facade.go +++ b/internal/node/openai_facade.go @@ -81,6 +81,11 @@ type openAIFacade struct { // peerLabels resolves a peer's gossip-observed labels for providers // discovered via the registry probe, which carries no labels. peerLabels func(peerID string) map[string]string + // egressFloor (may be nil) is the operator's egress floor. Remotes are + // held to it by the label gate, on attested facts; a local service has no + // biscuit and no gate, so ranking is the only place its floor can be + // applied at all. + egressFloor func() map[string]string // Label gate seam (may be nil = enforcement unavailable, fail closed // when a requirement exists): verifies the provider's // control-plane-attested labels before any request data is sent @@ -141,6 +146,7 @@ func newOpenAIFacade(node *SamNode, egress http.Handler) *openAIFacade { return node.revokedPeers != nil && node.revokedPeers.Contains(peerID) }, localLabels: node.labels, + egressFloor: node.egressFloor, peerLabels: func(peerID string) map[string]string { if node.Discovery == nil { return nil @@ -404,8 +410,10 @@ func (f *openAIFacade) handleCompletions(w http.ResponseWriter, r *http.Request) } else { // Labels ranked this provider; the label gate is the enforcement // point: the provider's biscuit must attest the requirement before - // the request body leaves this node. - if len(requiredLabels) > 0 { + // the request body leaves this node. The gate also runs when only + // the operator's floor requires it — a caller that asked for + // nothing is still held to the floor (VerifyPeerLabels ANDs both). + if len(requiredLabels) > 0 || len(f.floor()) > 0 { if f.verifyPeerLabels == nil { recordFacadeRejection(reasonLabelUnattested) writeOpenAIError(w, http.StatusServiceUnavailable, "label_unattested", @@ -415,9 +423,16 @@ func (f *openAIFacade) handleCompletions(w http.ResponseWriter, r *http.Request) // No backoff on failure: the verdict is requirement-scoped, // the provider stays eligible for unconstrained requests. if err := f.verifyPeerLabels(r.Context(), p.peerID, requiredLabels); err != nil { - recordFacadeRejection(reasonLabelUnattested) - logger.Warnf("[OpenAIFacade] provider peer=%q service=%q failed label attestation for %v: %v; trying next", - p.peerID, p.service, requiredLabels, err) + // With no caller requirement the only thing that can have + // failed is the floor; attribute it so the operator can + // tell their floor apart from a caller's requirement. + if len(requiredLabels) == 0 { + recordFacadeRejection(reasonEgressFloorMismatch) + } else { + recordFacadeRejection(reasonLabelUnattested) + } + logger.Warnf("[OpenAIFacade] provider peer=%q service=%q failed label attestation for %v (egress floor %v): %v; trying next", + p.peerID, p.service, requiredLabels, f.floor(), err) continue } } diff --git a/internal/node/openai_facade_test.go b/internal/node/openai_facade_test.go index 748b2047..7fd8e3fb 100644 --- a/internal/node/openai_facade_test.go +++ b/internal/node/openai_facade_test.go @@ -823,3 +823,158 @@ func TestAttemptWriter(t *testing.T) { } }) } + +// A local service has no biscuit, so the label gate can never speak for it and +// ranking is the only place an egress floor can apply. A remote is filtered +// here only on claims that already contradict the floor; an unlabelled one is +// left for the gate, which decides on attested facts. +func TestRankProvidersEnforcesEgressFloor(t *testing.T) { + floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + newFacadeWithFloor := func(local map[string]string, peer map[string]string) *openAIFacade { + f := newTestFacade() + f.egressFloor = func() map[string]string { return floor } + f.localLabels = func() map[string]string { return local } + f.peerLabels = func(string) map[string]string { return peer } + return f + } + + t.Run("a local outside the floor is dropped", func(t *testing.T) { + f := newFacadeWithFloor(map[string]string{"jurisdiction": "us"}, nil) + got := f.rankProviders([]modelProvider{{service: "local"}}, nil) + if len(got) != 0 { + t.Errorf("a local that does not satisfy the floor must not be used: got %+v", got) + } + }) + + t.Run("a local one pair short is dropped", func(t *testing.T) { + f := newFacadeWithFloor(map[string]string{"jurisdiction": "eu"}, nil) + if got := f.rankProviders([]modelProvider{{service: "local"}}, nil); len(got) != 0 { + t.Errorf("the floor is a conjunction: got %+v", got) + } + }) + + t.Run("a local satisfying every pair survives", func(t *testing.T) { + f := newFacadeWithFloor(map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}, nil) + if got := f.rankProviders([]modelProvider{{service: "local"}}, nil); len(got) != 1 { + t.Errorf("a local inside the floor must be usable: got %+v", got) + } + }) + + t.Run("a remote whose claims contradict the floor is dropped early", func(t *testing.T) { + f := newFacadeWithFloor(nil, nil) + p := modelProvider{peerID: "peerUS", service: "srv", labels: map[string]string{"jurisdiction": "us"}} + if got := f.rankProviders([]modelProvider{p}, nil); len(got) != 0 { + t.Errorf("a remote claiming outside the floor need not be dialled: got %+v", got) + } + }) + + // Gossip carries only part of what a peer attests, so a remote silent on + // one pair of the floor may still satisfy all of it in its Biscuit. + // Dropping it here would exclude a provider that is inside the boundary. + t.Run("a remote gossiping only part of the floor is left to the gate", func(t *testing.T) { + f := newFacadeWithFloor(nil, nil) + p := modelProvider{peerID: "peerEU", service: "srv", labels: map[string]string{"jurisdiction": "eu"}} + if got := f.rankProviders([]modelProvider{p}, nil); len(got) != 1 { + t.Errorf("silence on a pair is not a contradiction; the gate decides: got %+v", got) + } + }) + + t.Run("an unlabelled remote is left to the gate", func(t *testing.T) { + f := newFacadeWithFloor(nil, nil) + p := modelProvider{peerID: "peerUnknown", service: "srv"} + if got := f.rankProviders([]modelProvider{p}, nil); len(got) != 1 { + t.Errorf("ranking must not reject on absent claims; the gate decides: got %+v", got) + } + }) + + t.Run("no floor configured leaves ranking unchanged", func(t *testing.T) { + f := newTestFacade() + f.localLabels = func() map[string]string { return map[string]string{"jurisdiction": "us"} } + if got := f.rankProviders([]modelProvider{{service: "local"}}, nil); len(got) != 1 { + t.Errorf("without a floor a local is unconstrained: got %+v", got) + } + }) +} + +// Ranking is a hint; the gate on the forward path is the enforcement. Gossip is +// unauthenticated, so an unlabelled remote survives ranking and the biscuit +// gate is the only check left before the body leaves the node — it must run +// even when the caller required nothing, or the floor is waived by silence. +func TestFacade_Completions_FloorGatesSilentCaller(t *testing.T) { + newFloorFacade := func() *openAIFacade { + f := newTestFacade() + f.egressFloor = func() map[string]string { return map[string]string{"jurisdiction": "eu"} } + f.viewProviders = func(string) []modelProvider { + return []modelProvider{{peerID: "peerX", service: "srv"}} + } + return f + } + silentRequest := func() *http.Request { + // No X-Sam-Required-Labels header: the caller requires nothing. + return httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m1"}`)) + } + + t.Run("an unattested floor blocks the forward", func(t *testing.T) { + f := newFloorFacade() + var gateRequired map[string]string + gateCalled := false + f.verifyPeerLabels = func(_ context.Context, _ string, required map[string]string) error { + gateCalled = true + gateRequired = required + return fmt.Errorf("floor not attested") + } + forwarded := false + f.forward = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { forwarded = true }) + + rec := httptest.NewRecorder() + f.handleCompletions(rec, silentRequest()) + + if !gateCalled { + t.Fatal("the gate must run for a caller that required nothing") + } + if gateRequired != nil { + t.Errorf("the caller required nothing, so the gate must see required=nil, got %v", gateRequired) + } + if forwarded { + t.Error("the body must not leave the node when the floor is unattested") + } + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + }) + + t.Run("an attested floor forwards", func(t *testing.T) { + f := newFloorFacade() + f.verifyPeerLabels = func(context.Context, string, map[string]string) error { return nil } + forwarded := false + f.forward = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + forwarded = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + + rec := httptest.NewRecorder() + f.handleCompletions(rec, silentRequest()) + + if rec.Code != http.StatusOK || !forwarded { + t.Errorf("an attested floor must not block: status = %d, forwarded = %v", rec.Code, forwarded) + } + }) + + t.Run("a floor with no gate seam fails closed", func(t *testing.T) { + f := newFloorFacade() // verifyPeerLabels stays nil + forwarded := false + f.forward = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { forwarded = true }) + + rec := httptest.NewRecorder() + f.handleCompletions(rec, silentRequest()) + + if forwarded { + t.Error("no gate available must mean no egress, not ungated egress") + } + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + }) +} diff --git a/internal/node/openai_scorer.go b/internal/node/openai_scorer.go index b7a34561..055cee7d 100644 --- a/internal/node/openai_scorer.go +++ b/internal/node/openai_scorer.go @@ -18,6 +18,8 @@ import ( "net/http" "sort" "time" + + "github.com/google/sam/api" ) // providerBackoff is how long a provider is skipped after a retryable failure. @@ -25,14 +27,27 @@ const providerBackoff = 15 * time.Second // Rejection reasons for facade provider filtering (metric label values). const ( - reasonPeerRevoked = "peer_revoked" - reasonProviderBackoff = "provider_backoff" - reasonLabelMismatch = "label_mismatch" - reasonLabelUnattested = "label_unattested" - reasonNoEligible = "no_eligible_provider" - reasonAttemptsExceeded = "attempts_exceeded" + reasonPeerRevoked = "peer_revoked" + reasonProviderBackoff = "provider_backoff" + reasonLabelMismatch = "label_mismatch" + // reasonEgressFloorMismatch is kept distinct from reasonLabelMismatch so + // an operator can tell their own floor apart from a caller's requirement + // when a request finds no provider. + reasonEgressFloorMismatch = "egress_floor_mismatch" + reasonLabelUnattested = "label_unattested" + reasonNoEligible = "no_eligible_provider" + reasonAttemptsExceeded = "attempts_exceeded" ) +// floor returns the operator's egress floor, or nil when the seam is unset +// (tests) or no floor is configured. +func (f *openAIFacade) floor() map[string]string { + if f.egressFloor == nil { + return nil + } + return f.egressFloor() +} + // labelsAllowed reports whether a provider's claimed labels satisfy any // required key=value pair (exact match). func labelsAllowed(required, claimed map[string]string) bool { @@ -71,6 +86,30 @@ func (f *openAIFacade) rankProviders(providers []modelProvider, requiredLabels m continue } } + // The operator's egress floor, which the caller cannot waive. Every + // pair must hold, so this is not labelsAllowed. A remote is dropped + // here only on a claim that already contradicts the floor; the gate + // still decides on attested facts. A local is decided here for good, + // because it has no biscuit to attest anything. + if floor := f.floor(); len(floor) > 0 { + // A local is settled here: its labels are its own configuration, + // it has no Biscuit, and it never reaches the gate, so the floor + // must hold in full. + // + // A remote is only skipped on a claim that conflicts. Gossip may + // carry part of what a peer attests, so silence on a pair of the + // floor is not failure — the gate resolves it on attested facts. + var outside bool + if p.peerID == "" { + outside = !api.LabelsSatisfyFloor(floor, labels) + } else { + outside = api.LabelsContradictFloor(floor, labels) + } + if outside { + recordFacadeRejection(reasonEgressFloorMismatch) + continue + } + } if p.peerID == "" { locals = append(locals, p) continue diff --git a/internal/node/proxy_test.go b/internal/node/proxy_test.go index 1bc33ef2..b17885fd 100644 --- a/internal/node/proxy_test.go +++ b/internal/node/proxy_test.go @@ -31,6 +31,7 @@ import ( "time" "github.com/google/sam/api" + "github.com/google/sam/internal/identity" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" ) @@ -844,3 +845,116 @@ func TestDatapathHeadersAndRoutingTable(t *testing.T) { }) } } + +// The operator's egress floor holds at the /sam/ chokepoint itself, so an agent +// that skips the facade and dials /sam//... raw is still gated: the +// provider's biscuit must attest every pair of the floor before anything is +// forwarded, whatever the caller did or did not ask for (see #385). +func TestEgressProxyEnforcesFloor(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + nodeA, cleanupA := startBareNode(t, ctx) // provider + defer cleanupA() + nodeB, cleanupB := startBareNode(t, ctx) // egress side, holds the floor + defer cleanupB() + + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen root key: %v", err) + } + + // nodeB authenticates to nodeA as an unrestricted caller. + if err := buildAndSaveBiscuit(nodeB, rootPriv); err != nil { + t.Fatalf("buildAndSaveBiscuit: %v", err) + } + nodeA.keysMu.Lock() + nodeA.trustedKeys = append(nodeA.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeA.keysMu.Unlock() + + // nodeA attests jurisdiction=eu and nothing else. + nodeAIdentity, err := identity.MintBootstrapBiscuitToken(rootPriv, nodeA.Host.ID(), api.RoleNode, time.Now().Add(time.Hour), nil, map[string]string{"jurisdiction": "eu"}) + if err != nil { + t.Fatalf("mint nodeA identity: %v", err) + } + nodeA.SetIdentityCache(nodeAIdentity) + nodeB.keysMu.Lock() + nodeB.trustedKeys = append(nodeB.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeB.keysMu.Unlock() + + if err := nodeB.Host.Connect(ctx, peer.AddrInfo{ID: nodeA.Host.ID(), Addrs: nodeA.Host.Addrs()}); err != nil { + t.Fatal(err) + } + + const expectedBody = `{"status":"floored"}` + dummyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(expectedBody)) + })) + defer dummyServer.Close() + + serviceInfo := &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "dummy-tool"} + targetURL, _ := url.Parse(dummyServer.URL) + nodeA.services.insertService(&testService{ + info: serviceInfo, + handler: httputil.NewSingleHostReverseProxy(targetURL), + }) + + proxyServer := httptest.NewServer(createEgressProxy(nodeB)) + defer proxyServer.Close() + // The caller stays silent: no X-Sam-Required-Labels anywhere. + rawURL := fmt.Sprintf("%s/sam/%s/mcp/dummy-tool/api/v1/test", proxyServer.URL, nodeA.Host.ID()) + + get := func() *http.Response { + t.Helper() + resp, err := http.Get(rawURL) + if err != nil { + t.Fatal(err) + } + return resp + } + + // nodeA attests the whole floor: egress proceeds. + nodeB.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu"}} + var resp *http.Response + for i := 0; i < 3; i++ { + resp = get() + if resp.StatusCode == http.StatusOK { + break + } + _ = resp.Body.Close() + time.Sleep(time.Second) + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || string(body) != expectedBody { + t.Fatalf("egress inside the floor must pass: status %d, body %q", resp.StatusCode, body) + } + + // A non-canonical spelling of the same peer (CID form) is canonicalized at + // the gate boundary, so the verdict and the dial name the same peer. + cidURL := fmt.Sprintf("%s/sam/%s/mcp/dummy-tool/api/v1/test", proxyServer.URL, peer.ToCid(nodeA.Host.ID())) + resp, err = http.Get(cidURL) + if err != nil { + t.Fatal(err) + } + body, err = io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || string(body) != expectedBody { + t.Fatalf("a CID-spelled peer inside the floor must pass: status %d, body %q", resp.StatusCode, body) + } + + // One pair short of the floor: refused at the chokepoint, nothing forwarded. + nodeB.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}} + resp = get() + _ = resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("egress outside the floor must be 403 at the chokepoint, got %d", resp.StatusCode) + } +} diff --git a/internal/node/sidecar.go b/internal/node/sidecar.go index c2429858..c8a2ffa6 100644 --- a/internal/node/sidecar.go +++ b/internal/node/sidecar.go @@ -711,6 +711,39 @@ func createEgressProxy(node *SamNode) http.Handler { return } + // The operator's egress floor (egress.require_labels) holds at this + // chokepoint whatever the surface, so an agent that skips the facade + // and dials /sam//... raw is gated the same way. required is nil: + // any caller requirement was already enforced by the surface that + // parsed it; the floor is what a silent caller cannot waive. + if floor := node.egressFloor(); len(floor) > 0 { + route, ok := parseEgressRoute(r.URL.Path) + if !ok { + http.Error(w, "Forbidden: egress floor in force and request names no peer", http.StatusForbidden) + return + } + pid, err := peer.Decode(route.peerID) + if err != nil { + http.Error(w, "Bad Request: invalid peer ID", http.StatusBadRequest) + return + } + // The verdict is for the canonical peer, so the dial must name the + // same form: rewrite the segment the Director will re-parse rather + // than let a non-canonical spelling travel past the gate. + if canonical := pid.String(); route.peerID != canonical { + parts := strings.SplitN(r.URL.Path, "/", 6) + if len(parts) >= 3 { + parts[2] = canonical + r.URL.Path = strings.Join(parts, "/") + } + } + if err := node.VerifyPeerLabels(r.Context(), pid, nil); err != nil { + logger.Warnf("[Egress] floor gate refused egress to %s: %v", pid, err) + http.Error(w, "Forbidden: provider does not attest the egress floor", http.StatusForbidden) + return + } + } + r.Header.Set(api.HeaderSamBiscuit, base64.StdEncoding.EncodeToString(biscuitBytes)) // Forwarded, not stripped: the agent claim is what lets the peer at the diff --git a/mobile/sam-node-ffi/ffi/ffi.go b/mobile/sam-node-ffi/ffi/ffi.go index e0082d5a..bdbafe2f 100644 --- a/mobile/sam-node-ffi/ffi/ffi.go +++ b/mobile/sam-node-ffi/ffi/ffi.go @@ -67,6 +67,10 @@ type MobileConfig struct { // Local attenuation, same shape as the config file's block. Go matches // these keys to the yaml-tagged fields case-insensitively. Attenuation api.Attenuation `json:"attenuation"` + // Egress mirrors the config file's egress block: the operator's floor on + // the peers this node calls (api.Egress). JSON spelling is requireLabels, + // matched case-insensitively to the Go field, not the yaml require_labels. + Egress api.Egress `json:"egress"` } // MobileService is one statically declared service. @@ -191,6 +195,7 @@ func StartNode(configJSON string) error { Attenuation: config.Attenuation, Services: services, Labels: config.Labels, + Egress: config.Egress, }) if err != nil { _ = store.Close() diff --git a/mobile/sam-node-ffi/ffi/ffi_test.go b/mobile/sam-node-ffi/ffi/ffi_test.go index 52138435..23c48807 100644 --- a/mobile/sam-node-ffi/ffi/ffi_test.go +++ b/mobile/sam-node-ffi/ffi/ffi_test.go @@ -125,6 +125,8 @@ func TestMobileFFILifecycle(t *testing.T) { ApiToken: "test-token", AllowLoopback: true, Labels: map[string]string{"region": "eu-west-1"}, + // A valid floor must not stop startup; enforcement is internal/node's. + Egress: api.Egress{RequireLabels: map[string]string{"jurisdiction": "eu"}}, } cfgBytes, _ := json.Marshal(cfg) @@ -200,6 +202,19 @@ func TestMobileConfigDecodesAttenuation(t *testing.T) { } } +// Same pin for the egress floor: it rides the yaml-tagged api.Egress, so the +// JSON spelling is requireLabels. A silent miss here would start the node +// with no floor while the operator believes one is set. +func TestMobileConfigDecodesEgressFloor(t *testing.T) { + var config MobileConfig + if err := json.Unmarshal([]byte(`{"egress":{"requireLabels":{"jurisdiction":"eu"}}}`), &config); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if config.Egress.RequireLabels["jurisdiction"] != "eu" { + t.Fatalf("got %+v, want the floor pair decoded", config.Egress) + } +} + // Re-enrollment buys a JWT with the stored refresh token and re-attests the // new labels under the same PeerID, with no browser involved. func TestReEnrollNodeUsesStoredRefreshToken(t *testing.T) { diff --git a/site/content/docs/sovereignty.md b/site/content/docs/sovereignty.md index 91160e21..2b9cd1c6 100644 --- a/site/content/docs/sovereignty.md +++ b/site/content/docs/sovereignty.md @@ -135,5 +135,5 @@ When deploying SAM for mission-critical, sovereign agent operations: 2. **Maintain Root Cryptographic Key Custody:** Generate, manage, and hold your own Ed25519 root signing keys (via KMS/Cloud EKM or HSMs). 3. **Use Your Own OIDC Identity Provider:** Point `--issuer` to your internal Keycloak, Dex, or corporate IdP. 4. **Declare & Attest Sovereignty Labels:** Declare `labels: {jurisdiction: eu, region: }` in each node's `sam-node.yaml` and configure control plane roles with `allowed_labels`. -5. **Enforce Jurisdictional Egress:** Direct agents to specify `X-Sam-Required-Labels: jurisdiction=eu` on all inference and MCP requests to guarantee zero data leakage beyond authorized perimeters. +5. **Enforce Jurisdictional Egress:** Set `egress.require_labels` in `sam-node.yaml` (e.g. `jurisdiction: eu`) so every provider must attest the boundary before the node sends it anything. Agents may narrow further per request with `X-Sam-Required-Labels`, but cannot widen past the floor or waive it by omitting the header — the two are checked independently and every pair of the floor must hold. 6. **Set Local Attenuation Vetoes:** Configure local node `attenuation.policies` to retain final destination-side access control. diff --git a/site/content/docs/user/node-configuration.md b/site/content/docs/user/node-configuration.md index 05e8da68..07af2184 100644 --- a/site/content/docs/user/node-configuration.md +++ b/site/content/docs/user/node-configuration.md @@ -141,6 +141,28 @@ The `call_remote_tool` MCP tool accepts the same requirement via its `required_l Enforcement is fail-closed and cryptographic: gossiped labels only rank candidate providers, and before any request data leaves your node the sidecar verifies the provider's control-plane-signed Biscuit and checks its attested `label()` facts. Providers that return no identity or lack a matching fact are rejected. +### Requiring labels of every provider (operator floor) + +The header above is the caller's requirement, and a caller that sends no header is unconstrained. To hold a boundary the caller cannot waive, set a floor in the node config: + +```yaml +egress: + require_labels: # every pair must hold, and callers cannot widen it + jurisdiction: eu + compliance: gdpr +``` + +Every remote provider must then attest all of these before the node sends it anything, whether or not the caller asked for labels. A caller may still narrow the choice further with `X-Sam-Required-Labels`; the two are checked independently, so a caller naming an unrelated label cannot stand in for the floor. + +Note the difference in meaning between the two, which follows from what each is for: + +| | Semantics | +|---|---| +| `X-Sam-Required-Labels` (caller) | **any** pair is enough — the caller is choosing among acceptable providers | +| `egress.require_labels` (operator) | **every** pair must hold — the operator is drawing a boundary | + +A floor naming a label no provider attests reaches nothing, which makes it a usable egress kill switch. Omit the block entirely to keep the previous behaviour, where the requirement is whatever the caller supplied. + ### Restricting callers by label (provider) Because every enrolled node's token carries its attested label facts, a provider can require callers to hold a label with a single local check in its `attenuation` block: