From fe189d9d8d60b0e4a7de192ba7a4fff89b498861 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Wed, 9 Sep 2026 21:25:56 +0000 Subject: [PATCH] node: declare labels in the node config file instead of --labels Labels are declared at enrollment, but --labels was registered only on runCmd while joinCmd read the same variable, so `sam-node join` always enrolled with no labels at all. --config is a persistent flag, so moving the declaration into sam-node.yaml makes both enrollment paths carry it. The flag goes with no shim: the config schema is v1alpha1 and every caller lived in this repo. Validation moves to LoadNodeConfig, which names the offending file, and SamNode.LocalPolicy becomes nodeConfig now that the struct carries identity claims alongside policy. --- README.md | 2 +- api/policy.go | 7 ++-- charts/sam-mesh/values.yaml | 5 ++- charts/sam-node/values.yaml | 3 ++ cmd/sam-node/daemonize_test.go | 2 +- cmd/sam-node/main.go | 40 ------------------- cmd/sam-node/main_test.go | 19 --------- internal/node/config.go | 8 ++++ internal/node/config_test.go | 29 ++++++++++++++ internal/node/discovery_source.go | 2 +- internal/node/enroll.go | 4 +- internal/node/mcp_discovery_test.go | 4 +- internal/node/middleware.go | 8 ++-- internal/node/middleware_test.go | 6 +-- internal/node/node.go | 14 ++++++- internal/node/openai_facade.go | 2 +- internal/node/options.go | 10 ++--- .../docs/development/kubernetes-deployment.md | 2 +- site/content/docs/sovereignty.md | 2 +- site/content/docs/user/node-configuration.md | 22 +++++++--- tests/e2e/a2a_mesh.bats | 2 +- tests/e2e/auth_flows.bats | 38 ++++++++++++++++-- .../e2e/docker/a2a-echo/sam-node-config.yaml | 2 + tests/e2e/fixtures/sam-node-labels.yaml | 5 +++ tests/integration/a2a_test.go | 3 +- tests/integration/catalog_test.go | 28 +++++++++---- tests/integration/datapath_test.go | 4 +- tests/integration/federation_test.go | 2 +- tests/integration/openai_facade_test.go | 4 +- tests/integration/sandbox_boundary_test.go | 2 +- tests/integration/service_discovery_test.go | 4 +- 31 files changed, 167 insertions(+), 118 deletions(-) create mode 100644 tests/e2e/fixtures/sam-node-labels.yaml diff --git a/README.md b/README.md index 9bf32abb..ec634218 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ SAM provides the open protocols, cryptographic building blocks, and software to 1. **Deploy Dedicated Mesh Infrastructure:** Run a dedicated control plane (`sam-control-plane`) and routing relays (`sam-router`) on your chosen infrastructure (managed cloud environments like Google Cloud, private Kubernetes clusters, or air-gapped datacenters) using our [Helm chart](charts/sam-mesh/README.md) or Kubernetes manifests. 2. **Maintain Root Cryptographic Key Custody:** Generate, manage, and hold your own Ed25519 root signing keys (via local HSMs, KMS, or Cloud EKM). You maintain 100% of the cryptographic authority—no external party can mint credentials, revoke nodes, or alter policies. 3. **Bring Your Own Identity Provider:** Bridge agent and user identities through your own OIDC identity provider (such as Dex, Keycloak, or corporate IdP). -4. **Enforce Territorial & Jurisdictional Boundaries:** Use cryptographically attested label gates (`--labels jurisdiction=eu`, `X-Sam-Required-Labels`) to mathematically guarantee prompts and tool invocations never leave authorized geographic scopes. +4. **Enforce Territorial & Jurisdictional Boundaries:** Use cryptographically attested label gates (`labels: {jurisdiction: eu}` in the node config, `X-Sam-Required-Labels`) to mathematically guarantee prompts and tool invocations never leave authorized geographic scopes. 5. **Retain Autonomous Local Vetoes:** Configure local node attenuation policies (`sam-node.yaml`) to evaluate access rules *before* control plane grants, ensuring local nodes retain absolute veto authority. > [!NOTE] diff --git a/api/policy.go b/api/policy.go index 9c949eee..235bbf17 100644 --- a/api/policy.go +++ b/api/policy.go @@ -41,9 +41,10 @@ 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"` + Version string `yaml:"version"` + Attenuation Attenuation `yaml:"attenuation"` + Services []ServiceConfig `yaml:"services"` + Labels map[string]string `yaml:"labels,omitempty"` } // NodeConfigVersionV1Alpha1 is the only node config schema this build understands. diff --git a/charts/sam-mesh/values.yaml b/charts/sam-mesh/values.yaml index e33754e6..bd379eaf 100644 --- a/charts/sam-mesh/values.yaml +++ b/charts/sam-mesh/values.yaml @@ -191,6 +191,7 @@ bootstrap: # until a policy grants it (fail closed); never use ["*"] outside an # intentionally public mesh. nodeServices: [] - # Label patterns ("key=value" or "key=*") sam:role:node may attest with - # --labels. Empty means nodes cannot enroll with any label (fail closed). + # Label patterns ("key=value" or "key=*") sam:role:node may attest with the + # labels in its sam-node.yaml. Empty means nodes cannot enroll with any label + # (fail closed). nodeLabels: [] diff --git a/charts/sam-node/values.yaml b/charts/sam-node/values.yaml index 945412a0..63bff0e6 100644 --- a/charts/sam-node/values.yaml +++ b/charts/sam-node/values.yaml @@ -27,6 +27,9 @@ extraArgs: [] # advertises, and local attenuation. config: version: v1alpha1 + # Operator-declared labels the control plane attests, subject to the role's + # allowed_labels grant (e.g. region: us-east-1). + labels: {} attenuation: policies: [] checks: [] diff --git a/cmd/sam-node/daemonize_test.go b/cmd/sam-node/daemonize_test.go index fac7dc1f..4011e16d 100644 --- a/cmd/sam-node/daemonize_test.go +++ b/cmd/sam-node/daemonize_test.go @@ -38,7 +38,7 @@ func TestWithoutDaemonizeFlag(t *testing.T) { } // A value that merely looks like the flag must survive. - got = withoutDaemonizeFlag([]string{"run", "--labels", "mode=--daemonize"}) + got = withoutDaemonizeFlag([]string{"run", "--log-level", "mode=--daemonize"}) if len(got) != 3 { t.Errorf("flag values must not be stripped: got %v", got) } diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index 111eae8d..9c65be62 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -92,7 +92,6 @@ var ( apiTokenPathFlag string bootstrapTokenPathFlag string clientSecretPathFlag string - labelsFlag string tlsCertFlag string tlsKeyFlag string tlsCAFlag string @@ -259,31 +258,6 @@ func interactiveJoin(ctx context.Context, store *node.Store, targetControlPlane return jwtStr, info, nil } -// parseLabelsFlag parses a comma-separated "key=value" list (see -// api/labels.go) into a label map; an empty string means no claims. -func parseLabelsFlag(s string) (map[string]string, error) { - if s == "" { - return nil, nil - } - labels := make(map[string]string) - for _, part := range strings.Split(s, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - k, v, ok := strings.Cut(part, "=") - if !ok { - return nil, fmt.Errorf("invalid label %q: expected key=value", part) - } - key := strings.TrimSpace(k) - if _, exists := labels[key]; exists { - return nil, fmt.Errorf("duplicate label key %q", key) - } - labels[key] = strings.TrimSpace(v) - } - return labels, nil -} - func main() { rootCmd := &cobra.Command{ Use: "sam-node", @@ -327,10 +301,6 @@ func main() { if jwtFlag != "" { logger.Warn("--jwt passes a secret on the command line; prefer --jwt-path") } - labels, err := parseLabelsFlag(labelsFlag) - if err != nil { - logger.Fatalf("Invalid --labels: %v", err) - } store, err := node.NewStore(resolveDataDir()) if err != nil { @@ -491,7 +461,6 @@ func main() { AutoRelayBackoff: autoRelayBackoffFlag, RouterConnectTimeout: routerConnectTimeoutFlag, RequiredRole: api.RoleNode, - Labels: labels, PolicySyncInterval: policySyncIntervalFlag, DHTProviderAddrTTL: dhtProviderAddrTTLFlag, DHTMaxRecordAge: dhtMaxRecordAgeFlag, @@ -559,7 +528,6 @@ func main() { AutoRelayBackoff: autoRelayBackoffFlag, RouterConnectTimeout: routerConnectTimeoutFlag, RequiredRole: api.RoleNode, - Labels: labels, PolicySyncInterval: policySyncIntervalFlag, DHTProviderAddrTTL: dhtProviderAddrTTLFlag, DHTMaxRecordAge: dhtMaxRecordAgeFlag, @@ -630,7 +598,6 @@ func main() { AutoRelayBackoff: autoRelayBackoffFlag, RouterConnectTimeout: routerConnectTimeoutFlag, RequiredRole: api.RoleNode, - Labels: labels, PolicySyncInterval: policySyncIntervalFlag, }) if err != nil { @@ -746,11 +713,6 @@ func main() { } } - labels, err := parseLabelsFlag(labelsFlag) - if err != nil { - logger.Fatalf("Invalid --labels: %v", err) - } - priv := node.GetOrGenerateKey(store) meshNode, err := node.NewSamNode(node.Options{ PrivKey: priv, @@ -771,7 +733,6 @@ func main() { AutoRelayBackoff: 3 * time.Second, RouterConnectTimeout: routerConnectTimeoutFlag, RequiredRole: api.RoleNode, - Labels: labels, PolicySyncInterval: policySyncIntervalFlag, }) if err != nil { @@ -885,7 +846,6 @@ func main() { joinCmd.Flags().StringVar(&bootstrapTokenFlag, "bootstrap-token", "", "Pre-shared bootstrap token for enrollment") joinCmd.Flags().StringVar(&bootstrapTokenPathFlag, "bootstrap-token-path", "", "Path to file containing the bootstrap token (recommended over --bootstrap-token)") runCmd.Flags().StringVar(&apiTokenPathFlag, "api-token-path", "", "Path to file containing the static Bearer token for API authorization (or env SAM_API_TOKEN)") - runCmd.Flags().StringVar(&labelsFlag, "labels", "", "Operator-declared key=value labels of this node, comma-separated (e.g. \"region=us-east-1,team=platform\"); empty means no claims") runCmd.Flags().StringVar(&tlsCertFlag, "tls-cert", "", "Path to TLS certificate for sidecar API") runCmd.Flags().StringVar(&tlsKeyFlag, "tls-key", "", "Path to TLS key for sidecar API") runCmd.Flags().StringVar(&tlsCAFlag, "tls-ca", "", "Path to TLS CA for sidecar API mTLS") diff --git a/cmd/sam-node/main_test.go b/cmd/sam-node/main_test.go index 76d97034..ee9cb929 100644 --- a/cmd/sam-node/main_test.go +++ b/cmd/sam-node/main_test.go @@ -62,25 +62,6 @@ func TestResolveSocketPath(t *testing.T) { }) } -func TestParseLabelsFlag(t *testing.T) { - if got, err := parseLabelsFlag(""); got != nil || err != nil { - t.Errorf("empty flag: got %v, %v; want nil, nil", got, err) - } - - got, err := parseLabelsFlag(" region=eu , team=platform ,,") - if err != nil || len(got) != 2 || got["region"] != "eu" || got["team"] != "platform" { - t.Errorf("parse should split key=value pairs: got %v, %v", got, err) - } - - if _, err := parseLabelsFlag("noequals"); err == nil { - t.Error("entry without '=' must be rejected") - } - - if _, err := parseLabelsFlag("region=us-east-1,region=us-west-1"); err == nil { - t.Error("duplicate label key must be rejected") - } -} - func TestNormalizeControlPlaneURL(t *testing.T) { cases := map[string]string{ "bananas.sam-mesh.dev": "https://bananas.sam-mesh.dev", diff --git a/internal/node/config.go b/internal/node/config.go index ba06cf5f..332a1b03 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -30,6 +30,7 @@ type NodeConfigComplete struct { Checks []biscuit.Check Rules []biscuit.Rule Services []api.ServiceConfig + Labels map[string]string } // LoadNodeConfig loads the node configuration from the specified path. @@ -56,8 +57,15 @@ func LoadNodeConfig(path string) (*NodeConfigComplete, error) { path, config.Version, api.NodeConfigVersionV1Alpha1) } + // The control plane attests this set at enrollment, so a malformed label + // must stop the node here rather than surface as a refused enrollment. + if err := api.ValidateLabels(config.Labels); err != nil { + return nil, fmt.Errorf("invalid node config %s: %w", path, err) + } + complete := &NodeConfigComplete{ Services: config.Services, + Labels: config.Labels, } for i, svc := range config.Services { diff --git a/internal/node/config_test.go b/internal/node/config_test.go index b462777a..ac54783c 100644 --- a/internal/node/config_test.go +++ b/internal/node/config_test.go @@ -257,6 +257,35 @@ attenuation: - 'deny if user("bob");' policiez: - 'deny if user("alice");' +`, + wantErr: true, + }, + { + name: "Labels are parsed", + yamlContent: ` +version: "v1alpha1" +labels: + region: "us-east-1" + team: "platform" +`, + wantErr: false, + verify: func(t *testing.T, config *NodeConfigComplete) { + if len(config.Labels) != 2 { + t.Fatalf("got %d labels, want 2: %v", len(config.Labels), config.Labels) + } + if config.Labels["region"] != "us-east-1" || config.Labels["team"] != "platform" { + t.Errorf("unexpected labels: %v", config.Labels) + } + }, + }, + { + // The label set is what the control plane attests, so a malformed + // one must stop the node at load rather than at enrollment. + name: "Malformed label key is rejected", + yamlContent: ` +version: "v1alpha1" +labels: + "bad key!": "us-east-1" `, wantErr: true, }, diff --git a/internal/node/discovery_source.go b/internal/node/discovery_source.go index dfe8d54e..8b860cbe 100644 --- a/internal/node/discovery_source.go +++ b/internal/node/discovery_source.go @@ -47,7 +47,7 @@ func capKeys(keys []string) []string { // discoverySource builds gossip announcements from the registered services: // inference services announce model IDs, MCP services announce tool names. func (n *SamNode) discoverySource() []discovery.Announcement { - labels := n.config.Labels // validated at startup + labels := n.labels() var out []discovery.Announcement for _, info := range n.services.List(api.ServiceType_SERVICE_TYPE_UNSPECIFIED) { svc, ok := n.services.Get(info.GetName()) diff --git a/internal/node/enroll.go b/internal/node/enroll.go index 44d73609..0fb9d4aa 100644 --- a/internal/node/enroll.go +++ b/internal/node/enroll.go @@ -86,7 +86,7 @@ func (n *SamNode) enrollHTTP(ctx context.Context, controlPlaneURL, jwt string, p PeerId: peerID.String(), PublicKey: pubBytes, RequestedRole: n.config.RequiredRole, - Labels: n.config.Labels, // validated at startup + Labels: n.labels(), } data, err := proto.Marshal(req) if err != nil { @@ -234,7 +234,7 @@ func (n *SamNode) EnrollBootstrap(ctx context.Context, controlPlaneURL string, b PeerId: n.Host.ID().String(), PublicKey: pubBytes, RequestedRole: n.config.RequiredRole, - Labels: n.config.Labels, // validated at startup + Labels: n.labels(), Timestamp: enrollTS, ChallengeSignature: enrollSig, } diff --git a/internal/node/mcp_discovery_test.go b/internal/node/mcp_discovery_test.go index cf53778c..d936261f 100644 --- a/internal/node/mcp_discovery_test.go +++ b/internal/node/mcp_discovery_test.go @@ -71,8 +71,8 @@ func (f *fakeToolService) Tools(_ context.Context) ([]string, error) { func TestDiscoverySource(t *testing.T) { node := &SamNode{ - services: NewServiceRegistry(&fakeDHT{}), - config: Options{Labels: map[string]string{"region": "EU"}}, + services: NewServiceRegistry(&fakeDHT{}), + nodeConfig: &NodeConfigComplete{Labels: map[string]string{"region": "EU"}}, } ctx := context.Background() diff --git a/internal/node/middleware.go b/internal/node/middleware.go index 7053140f..d8795436 100644 --- a/internal/node/middleware.go +++ b/internal/node/middleware.go @@ -274,14 +274,14 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. return fmt.Errorf("failed to inject target facts: %w", err) } - if n.LocalPolicy != nil { - for _, p := range n.LocalPolicy.Policies { + if n.nodeConfig != nil { + for _, p := range n.nodeConfig.Policies { authorizer.AddPolicy(p) } - for _, c := range n.LocalPolicy.Checks { + for _, c := range n.nodeConfig.Checks { authorizer.AddCheck(c) } - for _, r := range n.LocalPolicy.Rules { + for _, r := range n.nodeConfig.Rules { authorizer.AddRule(r) } } diff --git a/internal/node/middleware_test.go b/internal/node/middleware_test.go index ed9c4312..7d9d231d 100644 --- a/internal/node/middleware_test.go +++ b/internal/node/middleware_test.go @@ -582,7 +582,7 @@ attenuation: t.Fatal(err) } - var localPolicy *NodeConfigComplete + var nodeConfigComplete *NodeConfigComplete if tt.localPolicyYAML != "" { dir := t.TempDir() policyFile := filepath.Join(dir, "local_policy.yaml") @@ -590,7 +590,7 @@ attenuation: t.Fatal(err) } var err error - localPolicy, err = LoadNodeConfig(policyFile) + nodeConfigComplete, err = LoadNodeConfig(policyFile) if err != nil { t.Fatalf("failed to load local policy: %v", err) } @@ -598,7 +598,7 @@ attenuation: node := &SamNode{ trustedKeys: []TrustedKey{{Key: pub, ReceivedAt: time.Now()}}, - LocalPolicy: localPolicy, + nodeConfig: nodeConfigComplete, BiscuitTimeout: 500 * time.Millisecond, } diff --git a/internal/node/node.go b/internal/node/node.go index dfd99550..685bc613 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -152,7 +152,7 @@ type SamNode struct { receivedMsgs map[string][]string topics map[string]*pubsub.Topic mu sync.Mutex - LocalPolicy *NodeConfigComplete + nodeConfig *NodeConfigComplete revokedPeers *lru.Cache[string, int64] peerLabelGate *lru.Cache[string, time.Time] authPeers sync.Map @@ -282,7 +282,7 @@ func NewSamNode(cfg Options) (*SamNode, error) { receivedMsgs: make(map[string][]string), topics: make(map[string]*pubsub.Topic), authenticatedRouters: make(map[peer.ID]bool), - LocalPolicy: cfg.NodeConfig, + nodeConfig: cfg.NodeConfig, AllowLoopback: cfg.AllowLoopback, authSuccess: make(chan struct{}), reprovideTrigger: make(chan struct{}, 1), @@ -317,6 +317,16 @@ func NewSamNode(cfg Options) (*SamNode, error) { return node, nil } +// labels reports this node's operator-declared labels, validated at load. +// NewSamNode always leaves nodeConfig non-nil, but tests build SamNode +// literals directly, so the guard lives here rather than at each caller. +func (n *SamNode) labels() map[string]string { + if n.nodeConfig == nil { + return nil + } + return n.nodeConfig.Labels +} + // Start initializes the libp2p host, DHT, connects to the routers, and starts runtime components. func (n *SamNode) Start(ctx context.Context) error { if biscuitBytes := n.GetIdentity(); len(biscuitBytes) > 0 { diff --git a/internal/node/openai_facade.go b/internal/node/openai_facade.go index a55c31a4..481533ad 100644 --- a/internal/node/openai_facade.go +++ b/internal/node/openai_facade.go @@ -140,7 +140,7 @@ func newOpenAIFacade(node *SamNode, egress http.Handler) *openAIFacade { isRevoked: func(peerID string) bool { return node.revokedPeers != nil && node.revokedPeers.Contains(peerID) }, - localLabels: func() map[string]string { return node.config.Labels }, + localLabels: node.labels, peerLabels: func(peerID string) map[string]string { if node.Discovery == nil { return nil diff --git a/internal/node/options.go b/internal/node/options.go index 98f3b146..9f8a0367 100644 --- a/internal/node/options.go +++ b/internal/node/options.go @@ -74,10 +74,6 @@ type Options struct { DiscoveryConcurrency int // RequiredRole restricts enrollment and startup to only accept tokens containing this role. RequiredRole string - // Labels are operator-declared key=value claims for this node (e.g. - // {"region": "us-east-1"}, see api/labels.go). Empty means no claims; - // consumers with a label requirement will not select this node. - Labels map[string]string // PolicySyncInterval specifies how often the node syncs the mesh policy from the control plane. PolicySyncInterval time.Duration // PolicySyncJitter specifies the maximum jitter delay when scheduling policy syncs on event broadcasts. @@ -135,6 +131,9 @@ func (o *Options) Default() { if o.PolicySyncJitter <= 0 { o.PolicySyncJitter = 10 * time.Second } + if o.NodeConfig == nil { + o.NodeConfig = &NodeConfigComplete{} + } } // Validate verifies that the required options are provided and valid. @@ -148,8 +147,5 @@ func (o *Options) Validate() error { if o.RequiredRole == "" { return fmt.Errorf("RequiredRole must be specified") } - if err := api.ValidateLabels(o.Labels); err != nil { - return err - } return nil } diff --git a/site/content/docs/development/kubernetes-deployment.md b/site/content/docs/development/kubernetes-deployment.md index 588da9d3..c54f7862 100644 --- a/site/content/docs/development/kubernetes-deployment.md +++ b/site/content/docs/development/kubernetes-deployment.md @@ -81,7 +81,7 @@ and extra args pass through to helm: ./development/deploy-kind-service.sh ~/src/my-service # same service as a second, differently-labeled node: ./development/deploy-kind-service.sh development/examples/calc-mcp --release-name calc-b \ - --set-json 'extraArgs=["--discovery-interval=200ms","--labels=region=us-east-1"]' + --set-json 'extraArgs=["--discovery-interval=200ms"]' --set config.labels.region=us-east-1 ``` To write your own service, copy an example folder: a backend listening on a diff --git a/site/content/docs/sovereignty.md b/site/content/docs/sovereignty.md index e512ec1d..91160e21 100644 --- a/site/content/docs/sovereignty.md +++ b/site/content/docs/sovereignty.md @@ -134,6 +134,6 @@ When deploying SAM for mission-critical, sovereign agent operations: 1. **Deploy Dedicated Control Plane Infrastructure:** Use the Helm chart or Kubernetes manifests to launch `sam-control-plane` on your chosen sovereign infrastructure (managed cloud with customer keys, private Kubernetes, or bare metal). 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:** Run nodes with `--labels jurisdiction=eu,region=` and configure control plane roles with `allowed_labels`. +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. 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 49d97ab2..1addb5a2 100644 --- a/site/content/docs/user/node-configuration.md +++ b/site/content/docs/user/node-configuration.md @@ -18,12 +18,17 @@ SAM_API_TOKEN="secret" sam-node run --config ./sam-node.yaml ### Configuration Schema -The `sam-node.yaml` file supports defining local **Services** and local **Attenuation** security rules. +The `sam-node.yaml` file supports declaring the node's **Labels**, its local **Services**, and local **Attenuation** security rules. ```yaml version: "v1alpha1" -# 1. Define Local Services +# 1. Declare this node's operator labels (see section 4) +labels: + region: us-east-1 + team: platform + +# 2. Define Local Services services: # Example: Expose a local CLI MCP server to the mesh (stdio subprocess) - type: mcp @@ -43,7 +48,7 @@ services: description: "DeepSeek local inference proxy" target_url: "http://localhost:11434" -# 2. Define Local Security Identity (Zero Trust) +# 3. Define Local Security Identity (Zero Trust) attenuation: rules: # Example: Inject custom Datalog facts asserting local node state @@ -107,12 +112,17 @@ SAM supports attested key=value labels (e.g. `region`, `team`) so a request neve ### Declaring labels (provider) -Start the node with its operator-declared labels, a comma-separated `key=value` list: +Declare the node's operator labels in its configuration file: -```bash -sam-node run --labels region=us-east-1,team=platform ... +```yaml +version: "v1alpha1" +labels: + region: us-east-1 + team: platform ``` +Both `sam-node join` and `sam-node run` read this file (`--config` is a global flag), so the labels are declared on whichever of them enrols the node. Keys are 1-63 characters of `[a-zA-Z0-9_.-]`; a value must be non-empty, at most 255 characters, and free of `,`, `=` and control characters, since the wire format is a comma-separated `key=value` list. A malformed entry stops the node at startup. + Labels are declared at enrollment and **attested by the control plane**, but only the ones a role permits. Set `allowed_labels` on the node's role (see [control plane configuration](../control-plane-configuration/)); a role granting none means the node can declare none, and enrollment is refused if it tries. This applies to all three enrollment paths, including bootstrap requests an administrator approves by hand: approving says the identity may join, so the role grant is what says which labels it may carry. The control plane then mints one signed `label(key, value)` fact per declared label into the node's Biscuit. Matching is exact and case-sensitive; an empty value means no claim for that key. ### Requiring labels (consumer) diff --git a/tests/e2e/a2a_mesh.bats b/tests/e2e/a2a_mesh.bats index e7d3d6cb..402535cb 100644 --- a/tests/e2e/a2a_mesh.bats +++ b/tests/e2e/a2a_mesh.bats @@ -51,7 +51,7 @@ teardown() { echo "[$(date +%T)] Starting Node 2 (provider, region=eu) with the echo service" mesh_start_node 2 \ - "--log-level debug --labels region=eu" \ + "--log-level debug" \ "tests/e2e/docker/a2a-echo/sam-node-config.yaml" mesh_wait_for_log "${MESH_PREFIX}-node-2" "SAM Node Online" 20 mesh_wait_for_mcp_ready 2 20 diff --git a/tests/e2e/auth_flows.bats b/tests/e2e/auth_flows.bats index 266fa019..eeea49f5 100644 --- a/tests/e2e/auth_flows.bats +++ b/tests/e2e/auth_flows.bats @@ -49,12 +49,18 @@ teardown() { docker volume create "${data_vol}" CLEANUP_VOLUMES+=("${data_vol}") + # Labels are declared in the node config, never on the command line, so join + # is the path that has to carry them into enrollment. + local labels_config + labels_config=$(realpath tests/e2e/fixtures/sam-node-labels.yaml) + docker run -d --name "${node_name}-join" \ --network "${MESH_NETWORK}" \ $(mesh_get_add_hosts) \ -v "${data_vol}:/data" \ + -v "${labels_config}:/etc/sam/node-config.yaml:ro" \ "sam-node:local" \ - join --data-dir /data "http://sam-control-plane:8080" + join --config /etc/sam/node-config.yaml --data-dir /data "http://sam-control-plane:8080" MESH_CONTAINERS+=("${node_name}-join") run mesh_wait_for_log "${node_name}-join" "OAuth Device Authorization Flow" 20 @@ -68,19 +74,45 @@ teardown() { [[ "$(docker inspect -f '{{.State.ExitCode}}' "${node_name}-join")" -eq 0 ]] docker rm -f "${node_name}-join" >/dev/null 2>&1 || true - # Now run the node with the stored identity + # Now run the node with the stored identity, on the same control plane it + # enrolled against: a mismatch is fatal, as is a tokenless TCP sidecar. docker run -d \ --name "${node_name}" \ --network "${MESH_NETWORK}" \ $(mesh_get_add_hosts) \ -v "${data_vol}:/data" \ + -e SAM_API_TOKEN="secret-token" \ "sam-node:local" \ run \ --data-dir /data \ - --control-plane "http://sam-control-plane:9090" + --control-plane "http://sam-control-plane:8080" MESH_CONTAINERS+=("${node_name}") mesh_wait_for_log "${node_name}" "Using stored identity." 20 + mesh_wait_for_log "${node_name}" "Serving the local API on Unix socket" 30 + + # The labels join declared must come back attested. /sam/identity hands back + # the raw biscuit rather than decoded claims, so read the signed + # label("region", "eu") fact out of its symbol table. The Unix socket is the + # only transport that endpoint accepts besides mTLS. + local deadline=$((SECONDS + 30)) + local evidence="" biscuit="" + while ((SECONDS < deadline)); do + evidence=$(docker run --rm -v "${data_vol}:/data" python:3.12 \ + curl -s --unix-socket /data/sam.sock http://localhost/sam/identity 2>&1) + biscuit=$(echo "${evidence}" | jq -r '.biscuit // empty' 2>/dev/null) + if [[ -n "${biscuit}" ]] && python3 -c " +import base64, sys +raw = base64.b64decode(sys.argv[1]) +sys.exit(0 if b'\x05label' in raw and b'\x06region' in raw and b'\x02eu' in raw else 1) +" "${biscuit}"; then + return 0 + fi + sleep 1 + done + echo "join did not carry config labels into enrollment; last evidence: ${evidence}" + docker logs --tail 30 "${node_name}" 2>&1 || true + return 1 } @test "Authentication Flow 3: Workload Identity Federation (JWT Path)" { diff --git a/tests/e2e/docker/a2a-echo/sam-node-config.yaml b/tests/e2e/docker/a2a-echo/sam-node-config.yaml index 0ceb2f4b..38c31833 100644 --- a/tests/e2e/docker/a2a-echo/sam-node-config.yaml +++ b/tests/e2e/docker/a2a-echo/sam-node-config.yaml @@ -1,4 +1,6 @@ version: "v1alpha1" +labels: + region: "eu" attenuation: policies: [] services: diff --git a/tests/e2e/fixtures/sam-node-labels.yaml b/tests/e2e/fixtures/sam-node-labels.yaml new file mode 100644 index 00000000..14a626c0 --- /dev/null +++ b/tests/e2e/fixtures/sam-node-labels.yaml @@ -0,0 +1,5 @@ +# Operator-declared labels only: this fixture exists to prove "sam-node join" +# carries them into enrollment, where the control plane attests them. +version: "v1alpha1" +labels: + region: "eu" diff --git a/tests/integration/a2a_test.go b/tests/integration/a2a_test.go index 58df896d..04963c90 100644 --- a/tests/integration/a2a_test.go +++ b/tests/integration/a2a_test.go @@ -107,8 +107,7 @@ func TestA2ACUJ(t *testing.T) { "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", "--listen", "/ip4/127.0.0.1/tcp/0", "--discovery-interval", "100ms", - "--labels", "region=eu", - "--config", writeServicesConfig(t, homeA, svcDecl{Type: "a2a", Name: "echo-agent", TargetURL: agent.URL}), + "--config", writeNodeConfig(t, homeA, map[string]string{"region": "eu"}, svcDecl{Type: "a2a", Name: "echo-agent", TargetURL: agent.URL}), ) t.Log("Starting Node B (consumer)...") _ = startBackgroundNode(t, nodeBin, hubAddr, homeB, diff --git a/tests/integration/catalog_test.go b/tests/integration/catalog_test.go index 59db9619..de8e2110 100644 --- a/tests/integration/catalog_test.go +++ b/tests/integration/catalog_test.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "testing" "time" @@ -41,7 +42,7 @@ func tokenPath(t *testing.T, secret string) string { return p } -// svcDecl is one service entry for writeServicesConfig. +// svcDecl is one service entry for writeNodeConfig. type svcDecl struct { Type string Name string @@ -49,13 +50,24 @@ type svcDecl struct { Command []string } -// writeServicesConfig renders a node config file declaring the given -// services. Services only exist by declaration at startup: there is no -// runtime registration surface, so backends must be up before the node. -func writeServicesConfig(t *testing.T, dir string, services ...svcDecl) string { +// writeNodeConfig writes a node config declaring the node's operator labels +// and its static services, the only way to declare either. +func writeNodeConfig(t *testing.T, dir string, labels map[string]string, services ...svcDecl) string { t.Helper() var b strings.Builder - b.WriteString("version: \"v1alpha1\"\nservices:\n") + b.WriteString("version: \"v1alpha1\"\n") + if len(labels) > 0 { + b.WriteString("labels:\n") + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s: %q\n", k, labels[k]) + } + } + b.WriteString("services:\n") for _, s := range services { fmt.Fprintf(&b, " - type: %q\n name: %q\n description: \"integration test service\"\n", s.Type, s.Name) if s.TargetURL != "" { @@ -68,9 +80,9 @@ func writeServicesConfig(t *testing.T, dir string, services ...svcDecl) string { } } } - p := filepath.Join(dir, "services-config.yaml") + p := filepath.Join(dir, "node-config.yaml") if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil { - t.Fatalf("write services config: %v", err) + t.Fatalf("write node config: %v", err) } return p } diff --git a/tests/integration/datapath_test.go b/tests/integration/datapath_test.go index 2453dfdc..b107cca2 100644 --- a/tests/integration/datapath_test.go +++ b/tests/integration/datapath_test.go @@ -42,7 +42,7 @@ func TestIntegrationStdioDatapath(t *testing.T) { // The stdio service is declared in node A's configuration; there is no // runtime registration. serviceName := "stdio-tool" - cfgA := writeServicesConfig(t, homeA, svcDecl{Type: "mcp", Name: serviceName, Command: []string{"cat"}}) + cfgA := writeNodeConfig(t, homeA, nil, svcDecl{Type: "mcp", Name: serviceName, Command: []string{"cat"}}) // Start Node A t.Log("Starting Node A...") @@ -162,7 +162,7 @@ func TestIntegrationHTTPDatapath(t *testing.T) { defer dummyServer.Close() serviceName := "http-tool" - cfgA := writeServicesConfig(t, homeA, svcDecl{Type: "mcp", Name: serviceName, TargetURL: dummyServer.URL}) + cfgA := writeNodeConfig(t, homeA, nil, svcDecl{Type: "mcp", Name: serviceName, TargetURL: dummyServer.URL}) // Start Node A t.Log("Starting Node A...") diff --git a/tests/integration/federation_test.go b/tests/integration/federation_test.go index 5bb86b73..40698119 100644 --- a/tests/integration/federation_test.go +++ b/tests/integration/federation_test.go @@ -222,7 +222,7 @@ roles: "--discovery-interval", "100ms", "--enable-relay=true", "--allow-loopback=true", - "--config", writeServicesConfig(t, tmpDir, + "--config", writeNodeConfig(t, tmpDir, nil, svcDecl{Type: "mcp", Name: "federated-tool", TargetURL: mcpServer.URL}, svcDecl{Type: "mcp", Name: "raw-pipe", TargetURL: rawServer.URL}), ) diff --git a/tests/integration/openai_facade_test.go b/tests/integration/openai_facade_test.go index 59578700..729519dd 100644 --- a/tests/integration/openai_facade_test.go +++ b/tests/integration/openai_facade_test.go @@ -79,8 +79,8 @@ func TestOpenAIFacadeCUJ(t *testing.T) { "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", "--listen", "/ip4/127.0.0.1/tcp/0", "--discovery-interval", "100ms", - "--labels", "region=eu", // exercise the operator label claim end to end - "--config", writeServicesConfig(t, homeA, svcDecl{Type: "inference", Name: "test-llm", TargetURL: backend.URL}), + // labels exercise the operator label claim end to end + "--config", writeNodeConfig(t, homeA, map[string]string{"region": "eu"}, svcDecl{Type: "inference", Name: "test-llm", TargetURL: backend.URL}), ) t.Log("Starting Node B (consumer)...") _ = startBackgroundNode(t, nodeBin, hubAddr, homeB, diff --git a/tests/integration/sandbox_boundary_test.go b/tests/integration/sandbox_boundary_test.go index df69500d..782ea842 100644 --- a/tests/integration/sandbox_boundary_test.go +++ b/tests/integration/sandbox_boundary_test.go @@ -89,7 +89,7 @@ func TestSandboxBoundaryCUJ(t *testing.T) { "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", "--listen", "/ip4/127.0.0.1/tcp/0", "--discovery-interval", "100ms", - "--config", writeServicesConfig(t, homeA, + "--config", writeNodeConfig(t, homeA, nil, svcDecl{Type: "inference", Name: "test-llm", TargetURL: inference.URL}, svcDecl{Type: "mcp", Name: "calc", TargetURL: tools.URL}), ) diff --git a/tests/integration/service_discovery_test.go b/tests/integration/service_discovery_test.go index e6981b70..796120a1 100644 --- a/tests/integration/service_discovery_test.go +++ b/tests/integration/service_discovery_test.go @@ -54,7 +54,7 @@ func TestServiceDiscovery(t *testing.T) { "--discovery-interval", "100ms", "--bind-addr", "127.0.0.1:0", "--api-token-path", tokenPath(t, apiToken), - "--config", writeServicesConfig(t, homeA, svcDecl{Type: "mcp", Name: serviceName, TargetURL: mockServer.URL}), + "--config", writeNodeConfig(t, homeA, nil, svcDecl{Type: "mcp", Name: serviceName, TargetURL: mockServer.URL}), ) // Start Node B @@ -155,7 +155,7 @@ func TestServiceDiscoveryStreaming(t *testing.T) { "--discovery-interval", "100ms", "--bind-addr", "127.0.0.1:0", "--api-token-path", tokenPath(t, apiToken), - "--config", writeServicesConfig(t, homeA, svcDecl{Type: "mcp", Name: serviceName, TargetURL: mockServer.URL}), + "--config", writeNodeConfig(t, homeA, nil, svcDecl{Type: "mcp", Name: serviceName, TargetURL: mockServer.URL}), ) // Start Node B