From 71ea8e3d6efe6bfeec4652ba12efa4eb7f48d953 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 20:02:49 +0000 Subject: [PATCH 1/6] api: egress destinations and HTTP narrowing in the policy vocabulary A destination outside the mesh is a service of type egress whose name is the destination hostname, so a grant reads egress://api.github.com and the request fact service("egress", "api.github.com"); the grant compiler needs no new case. Egress names have no .sam.alt projection. PolicyRole.http narrows one allowed_services entry to HTTP methods and paths. The control plane compiles it into http_granted_service_* facts with granted_method and granted_path_* facts and withholds the plain grant for that entry; BaselineHTTPRules derive the plain grant only for a request whose method() and path() facts match, so the existing allow policies decide unchanged and a request without those facts (a tunnel, a bare stream) fails closed. Grants stay a union: a plain grant from another entry or role is not narrowed. PolicyConfig.egress is the admin's list of destinations: name, optional target_url, the name of the credential the serving node resolves locally, and the roles or labels that select the serving nodes. EgressAssignmentsResponse is what a node receives; it travels on a new endpoint so a node predating it keeps syncing rules unchanged. Request facts method, path, host and port join the vocabulary; the SDK Datalog artifact carries the new rules and fact names. --- api/datalog.go | 122 ++++ api/egress.go | 189 ++++++ api/egress_test.go | 164 +++++ api/http_grants.go | 192 ++++++ api/http_grants_test.go | 289 +++++++++ api/names.go | 11 +- api/network.go | 12 + api/policy_rules.go | 8 +- api/sam.pb.go | 625 ++++++++++++++----- api/sam.proto | 59 ++ hack/gen-sdk-datalog/main.go | 4 + internal/identity/biscuit.go | 14 +- internal/identity/http_grants_test.go | 76 +++ sdk/js/src/gen/datalog.ts | 14 + sdk/js/src/gen/sam_pb.ts | 213 ++++++- sdk/python/src/agent_mesh/_gen/datalog.json | 14 + sdk/python/src/agent_mesh/_proto/sam_pb2.py | 136 ++-- sdk/python/src/agent_mesh/_proto/sam_pb2.pyi | 45 +- 18 files changed, 1918 insertions(+), 269 deletions(-) create mode 100644 api/egress.go create mode 100644 api/egress_test.go create mode 100644 api/http_grants.go create mode 100644 api/http_grants_test.go create mode 100644 internal/identity/http_grants_test.go diff --git a/api/datalog.go b/api/datalog.go index 72a85102..e7fbf150 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -261,6 +261,94 @@ const ( // Example Datalog: service("mcp", "calculator") FactService = "service" + // Request facts. The verifying node injects them from the request on the + // wire, never from the token, so a holder cannot assert them. They are + // present when the node handles the request as HTTP, and absent on a + // stream that carries no HTTP request. + + // FactMethod is the HTTP method of the request as received, or "CONNECT" + // for a tunnel the node opens without terminating HTTP. + // Contains: biscuit.String(method) + // Example Datalog: deny if method($m), !($m == "GET") + FactMethod = "method" + + // FactPath is the request path as the backend sees it: leading slash, no + // query, with the mesh routing prefix removed. A tunnel carries path(""). + // Contains: biscuit.String(path) + // Example Datalog: deny if path($p), !$p.starts_with("/v2/public/") + FactPath = "path" + + // FactHost is the destination hostname of an egress request, lowercase, + // as authorized: the same string as the service name. + // Contains: biscuit.String(host) + // Example Datalog: deny if host("payroll.internal.example.com") + FactHost = "host" + + // FactPort is the destination port of an egress request. + // Contains: biscuit.Integer(port) + // Example Datalog: deny if port($p), !($p == 443) + FactPort = "port" + + // HTTP narrowing (PolicyRole.http). A narrowed allowed_services entry is + // minted as an http_granted_service_* fact instead of the plain + // granted_service_* one, together with the methods and paths it permits. + // BaselineHTTPRules derive the plain grant only for a request whose + // method() and path() facts match, so the ordinary allow policies apply + // unchanged and a request without those facts matches nothing. + // + // Every fact below is keyed by ($type, $key), where $key is the term the + // corresponding granted_service_* fact would carry: the exact name, the + // suffix with its leading dot, the prefix with its trailing dot, or "*". + + // FactHTTPGrantedServiceExact is granted_service_exact, narrowed. + // Contains: biscuit.String(serviceType), biscuit.String(serviceName) + FactHTTPGrantedServiceExact = "http_granted_service_exact" + + // FactHTTPGrantedServiceSuffix is granted_service_suffix, narrowed. + // Contains: biscuit.String(serviceType), biscuit.String(suffixPattern) + FactHTTPGrantedServiceSuffix = "http_granted_service_suffix" + + // FactHTTPGrantedServicePrefix is granted_service_prefix, narrowed. + // Contains: biscuit.String(serviceType), biscuit.String(prefixPattern) + FactHTTPGrantedServicePrefix = "http_granted_service_prefix" + + // FactHTTPGrantedServiceAll is granted_service_all, narrowed. Its key is "*". + // Contains: biscuit.String(serviceType) + FactHTTPGrantedServiceAll = "http_granted_service_all" + + // FactHTTPGrantedServiceAllTypes is granted_service_all_types, narrowed. + // Its type and key are both "*". + // Contains: biscuit.Bool(true) (marker fact) + FactHTTPGrantedServiceAllTypes = "http_granted_service_all_types" + + // FactGrantedMethod lists the methods a narrowed grant permits. + // Contains: biscuit.String(serviceType), biscuit.String(key), biscuit.Set of biscuit.String(method) + FactGrantedMethod = "granted_method" + + // FactGrantedMethodAny marks a narrowed grant that permits every method. + // Contains: biscuit.String(serviceType), biscuit.String(key) + FactGrantedMethodAny = "granted_method_any" + + // FactGrantedPathExact lists the exact paths a narrowed grant permits. + // Contains: biscuit.String(serviceType), biscuit.String(key), biscuit.Set of biscuit.String(path) + FactGrantedPathExact = "granted_path_exact" + + // FactGrantedPathPrefix permits every path under one prefix, one fact per prefix. + // Contains: biscuit.String(serviceType), biscuit.String(key), biscuit.String(prefix) + FactGrantedPathPrefix = "granted_path_prefix" + + // FactGrantedPathAny marks a narrowed grant that permits every path. + // Contains: biscuit.String(serviceType), biscuit.String(key) + FactGrantedPathAny = "granted_path_any" + + // FactHTTPMethodOK is derived when the request method satisfies a narrowed grant. + // Contains: biscuit.String(serviceType), biscuit.String(key) + FactHTTPMethodOK = "http_method_ok" + + // FactHTTPPathOK is derived when the request path satisfies a narrowed grant. + // Contains: biscuit.String(serviceType), biscuit.String(key) + FactHTTPPathOK = "http_path_ok" + // FactLabel is a control-plane-attested key=value label on the token's // node (see api/labels.go). The control plane mints one fact per // declared label, so a requirement is a single exact match: a node @@ -338,6 +426,11 @@ var ( // BaselineRules are the pre-compiled target evaluation rules for the node middleware. BaselineRules []biscuit.Rule + // BaselineHTTPRules derive the plain granted_service_* facts from the + // http_granted_service_* facts of a narrowed grant when the request's + // method() and path() satisfy it. Added wherever BaselineRules are. + BaselineHTTPRules []biscuit.Rule + // BaselineReplayCheck verifies that the client peer ID matches the connection peer ID. BaselineReplayCheck biscuit.Check @@ -375,6 +468,8 @@ type DatalogSources struct { Policies []string `json:"policies"` // Rules derive allow_network_target from target grants (BaselineRules). Rules []string `json:"rules"` + // HTTPRules derive service grants from narrowed grants (BaselineHTTPRules). + HTTPRules []string `json:"http_rules"` // AgentRules derive agent_authorized from agent grants (BaselineAgentRules). AgentRules []string `json:"agent_rules"` // TargetFactRules map identity facts to target_fact (TargetFactRules). @@ -458,6 +553,33 @@ func init() { BaselineRules = append(BaselineRules, r) } + // 2b. HTTP Narrowing Rules (PolicyRole.http). + // A narrowed grant yields the plain granted_service_* fact only when the + // request's method and path match, so the allow policies above decide as + // they do for any grant. Both axes must hold; an axis with no restriction + // carries the *_any marker. The rules are positive, so a request with no + // method() or path() fact derives nothing and a narrowed grant fails closed. + BaselineSources.HTTPRules = []string{ + fmt.Sprintf(`%s($t, $k) <- %s($m), %s($t, $k, $set), $set.contains($m)`, FactHTTPMethodOK, FactMethod, FactGrantedMethod), + fmt.Sprintf(`%s($t, $k) <- %s($t, $k)`, FactHTTPMethodOK, FactGrantedMethodAny), + fmt.Sprintf(`%s($t, $k) <- %s($p), %s($t, $k, $set), $set.contains($p)`, FactHTTPPathOK, FactPath, FactGrantedPathExact), + fmt.Sprintf(`%s($t, $k) <- %s($p), %s($t, $k, $prefix), $p.starts_with($prefix)`, FactHTTPPathOK, FactPath, FactGrantedPathPrefix), + fmt.Sprintf(`%s($t, $k) <- %s($t, $k)`, FactHTTPPathOK, FactGrantedPathAny), + fmt.Sprintf(`%s($t, $n) <- %s($t, $n), %s($t, $n), %s($t, $n), %s($t, $n)`, FactGrantedServiceExact, FactService, FactHTTPGrantedServiceExact, FactHTTPMethodOK, FactHTTPPathOK), + fmt.Sprintf(`%s($t, $s) <- %s($t, $n), %s($t, $s), $n.ends_with($s), %s($t, $s), %s($t, $s)`, FactGrantedServiceSuffix, FactService, FactHTTPGrantedServiceSuffix, FactHTTPMethodOK, FactHTTPPathOK), + fmt.Sprintf(`%s($t, $p) <- %s($t, $n), %s($t, $p), $n.starts_with($p), %s($t, $p), %s($t, $p)`, FactGrantedServicePrefix, FactService, FactHTTPGrantedServicePrefix, FactHTTPMethodOK, FactHTTPPathOK), + fmt.Sprintf(`%s($t) <- %s($t, $n), %s($t), %s($t, "*"), %s($t, "*")`, FactGrantedServiceAll, FactService, FactHTTPGrantedServiceAll, FactHTTPMethodOK, FactHTTPPathOK), + fmt.Sprintf(`%s(true) <- %s($t, $n), %s(true), %s("*", "*"), %s("*", "*")`, FactGrantedServiceAllTypes, FactService, FactHTTPGrantedServiceAllTypes, FactHTTPMethodOK, FactHTTPPathOK), + } + + for i, rStr := range BaselineSources.HTTPRules { + r, err := parser.FromStringRule(rStr) + if err != nil { + panic(fmt.Sprintf("failed to parse baseline http rule %d: %v", i, err)) + } + BaselineHTTPRules = append(BaselineHTTPRules, r) + } + var err error // BaselineReplayCheck prevents token theft/replay by ensuring the client_peer_id fact (embedded by the control plane during issuance) diff --git a/api/egress.go b/api/egress.go new file mode 100644 index 00000000..f0846614 --- /dev/null +++ b/api/egress.go @@ -0,0 +1,189 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/biscuit-auth/biscuit-go/v2" +) + +// credentialNameSyntax bounds a credential name to one safe file name: the +// serving node resolves it under its secrets directory, so it must not be +// able to name a path. +var credentialNameSyntax = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`) + +// ValidateEgressDestination checks one PolicyConfig.egress entry. roleNames +// are the roles the same document defines, so served_by can be checked +// against them; a label entry is checked for form only. +func ValidateEgressDestination(d *EgressDestination, roleNames map[string]bool) error { + if d == nil { + return fmt.Errorf("egress entry is nil") + } + if err := ValidateEgressName(d.GetName()); err != nil { + return err + } + if d.GetTargetUrl() != "" { + if err := validateEgressTargetURL(d.GetTargetUrl()); err != nil { + return fmt.Errorf("egress %q: %w", d.GetName(), err) + } + } + if d.GetCredential() != "" && !credentialNameSyntax.MatchString(d.GetCredential()) { + return fmt.Errorf("egress %q: credential %q must be a name of 1-64 chars of [a-zA-Z0-9_.-], not a path or a value", d.GetName(), d.GetCredential()) + } + if len(d.GetServedBy()) == 0 { + return fmt.Errorf("egress %q: served_by must select at least one role or label", d.GetName()) + } + for _, sel := range d.GetServedBy() { + if key, value, isLabel := strings.Cut(sel, "="); isLabel { + if err := ValidateLabelKey(key); err != nil { + return fmt.Errorf("egress %q: served_by %q: %w", d.GetName(), sel, err) + } + if err := ValidateLabelValue(value); err != nil { + return fmt.Errorf("egress %q: served_by %q: %w", d.GetName(), sel, err) + } + continue + } + if !roleNames[sel] { + return fmt.Errorf("egress %q: served_by %q is neither a role in this policy nor a key=value label", d.GetName(), sel) + } + } + return nil +} + +// ValidateEgressName checks a destination name: a lowercase hostname with no +// wildcard, port or path. It is the service name of egress://, so it +// must also be valid there. +func ValidateEgressName(name string) error { + if name == "" { + return fmt.Errorf("egress entry has no name") + } + if name != NormalizeMeshHost(name) { + return fmt.Errorf("egress name %q must be lowercase with no trailing dot", name) + } + if strings.ContainsAny(name, "*:/") { + return fmt.Errorf("egress name %q must be one hostname: no wildcard, port or path", name) + } + if err := ValidateServiceFormat(EgressServicePrefix + name); err != nil { + return err + } + return nil +} + +// ValidateEgressServicePattern checks an egress entry of allowed_services or +// http.service. The generic service validator accepts a path and any case, +// which for the other types name a service that may exist; an egress name is +// a hostname, matched against a lowercase destination name, so a path, a +// port, uppercase or a trailing dot would compile to a grant that matches +// nothing. Entries of other types pass through unchanged. +func ValidateEgressServicePattern(svc string) error { + svcType, name := ParseServiceTarget(svc) + if svcType != ServiceTypeStringEgress { + return nil + } + if err := ValidateServiceFormat(svc); err != nil { + return err + } + if name == "*" { + return nil + } + if strings.ContainsAny(name, ":/") { + return fmt.Errorf("invalid egress pattern %q: a destination is a hostname, without a scheme, a port or a path (write the path in the role's http entry)", svc) + } + if name != NormalizeMeshHost(name) { + return fmt.Errorf("invalid egress pattern %q: destination names are lowercase with no trailing dot", svc) + } + return nil +} + +// validateEgressTargetURL accepts an http or https URL with a host and no +// credential; a credential is named by EgressDestination.credential and +// resolved on the serving node. +func validateEgressTargetURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid target_url") + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("target_url %q must use http or https", raw) + } + if u.Host == "" { + return fmt.Errorf("target_url %q has no host", raw) + } + if u.User != nil { + return fmt.Errorf("target_url must not carry a credential; name one in credential instead") + } + if u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("target_url %q must not carry a query or a fragment", raw) + } + return nil +} + +// EgressTargetURL is where the serving node forwards requests for d: +// target_url when set, otherwise https on the destination name. +func EgressTargetURL(d *EgressDestination) string { + if d.GetTargetUrl() != "" { + return d.GetTargetUrl() + } + return "https://" + d.GetName() +} + +// EgressServedBy reports whether a node with these roles and labels is +// selected to serve d. +func EgressServedBy(d *EgressDestination, roles []string, labels map[string]string) bool { + for _, sel := range d.GetServedBy() { + if key, value, isLabel := strings.Cut(sel, "="); isLabel { + if labels[key] == value { + return true + } + continue + } + for _, r := range roles { + if r == sel { + return true + } + } + } + return false +} + +// BuildEgressServingRules grants each destination to the nodes that serve it: +// granted_service_exact("egress", name) <- role(r) or <- label(k, v) for every +// served_by entry. The serving node evaluates its own credential when a local +// client asks for the destination, so the grant has to reach it; the rules +// travel with the mesh policy like every other grant. +func BuildEgressServingRules(egress []*EgressDestination) []PolicyRule { + var rules []PolicyRule + for _, d := range egress { + if d == nil || d.GetName() == "" { + continue + } + head := biscuit.Predicate{Name: FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String(ServiceTypeStringEgress), biscuit.String(d.GetName())}} + for _, sel := range d.GetServedBy() { + var body biscuit.Predicate + if key, value, isLabel := strings.Cut(sel, "="); isLabel { + body = biscuit.Predicate{Name: FactLabel, IDs: []biscuit.Term{biscuit.String(key), biscuit.String(value)}} + } else { + body = biscuit.Predicate{Name: FactRole, IDs: []biscuit.Term{biscuit.String(sel)}} + } + r := biscuit.Rule{Head: head, Body: []biscuit.Predicate{body}} + rules = append(rules, PolicyRule{Rule: r, Text: renderRule(r)}) + } + } + return rules +} diff --git a/api/egress_test.go b/api/egress_test.go new file mode 100644 index 00000000..52dfa7da --- /dev/null +++ b/api/egress_test.go @@ -0,0 +1,164 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "slices" + "strings" + "testing" + + "github.com/biscuit-auth/biscuit-go/v2" +) + +func TestEgressServiceType(t *testing.T) { + got, err := ParseServiceType("egress") + if err != nil || got != ServiceType_SERVICE_TYPE_EGRESS { + t.Fatalf("ParseServiceType(egress) = %v, %v", got, err) + } + s, err := ServiceTypeToString(ServiceType_SERVICE_TYPE_EGRESS) + if err != nil || s != ServiceTypeStringEgress { + t.Fatalf("ServiceTypeToString = %q, %v", s, err) + } + for _, svc := range []string{"egress://api.github.com", "egress://*.internal.example.com", "egress://*"} { + if err := ValidateServiceFormat(svc); err != nil { + t.Errorf("ValidateServiceFormat(%q): %v", svc, err) + } + } + // The grant compiler needs no egress-specific case. + if f := BuildServiceDatalogFact("egress://*.internal.example.com"); f.Name != FactGrantedServiceSuffix || f.IDs[1] != biscuit.String(".internal.example.com") { + t.Errorf("suffix grant = %v", f) + } +} + +// TestValidateEgressServicePattern: a grant is a hostname pattern. The forms +// the generic validator lets through, a path or uppercase, would compile to a +// grant that matches no destination, so they are refused here. +func TestValidateEgressServicePattern(t *testing.T) { + for _, ok := range []string{ + "egress://api.github.com", + "egress://*.internal.example.com", + "egress://api.*", + "egress://*", + "*", + // Other types keep their own rules. + "mcp://Calc/add", + } { + if err := ValidateEgressServicePattern(ok); err != nil { + t.Errorf("%q: %v", ok, err) + } + } + for _, bad := range []string{ + "egress://api.github.com/v3", + "egress://api.github.com:443", + "egress://https://api.github.com", + "egress://API.github.com", + "egress://api.github.com.", + "egress://a*.example.com", + "egress://", + } { + if err := ValidateEgressServicePattern(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestEgressHasNoMeshHost(t *testing.T) { + if _, err := ParseMeshHost("api.github.com.egress.sam.alt"); err == nil || !strings.Contains(err.Error(), "egress") { + t.Errorf("ParseMeshHost accepted an egress projection: %v", err) + } + if _, err := MeshHost(ServiceType_SERVICE_TYPE_EGRESS, "api.github.com"); err == nil { + t.Error("MeshHost rendered an egress destination") + } + // The other types are unaffected. + if uri, err := ParseMeshHost("tools.mcp.sam.alt"); err != nil || uri != "mcp://tools" { + t.Errorf("ParseMeshHost(tools.mcp.sam.alt) = %q, %v", uri, err) + } +} + +func TestValidateEgressDestination(t *testing.T) { + roles := map[string]bool{"pep": true} + tests := []struct { + name string + d *EgressDestination + wantErr string + }{ + {"valid by role", &EgressDestination{Name: "api.github.com", Credential: "github-eu", ServedBy: []string{"pep"}}, ""}, + {"valid by label with target_url", &EgressDestination{Name: "mam.internal.example.com", TargetUrl: "http://mam.internal.example.com:8080", ServedBy: []string{"site=dc1"}}, ""}, + {"no name", &EgressDestination{ServedBy: []string{"pep"}}, "no name"}, + {"uppercase", &EgressDestination{Name: "API.github.com", ServedBy: []string{"pep"}}, "lowercase"}, + {"wildcard", &EgressDestination{Name: "*.github.com", ServedBy: []string{"pep"}}, "one hostname"}, + {"port", &EgressDestination{Name: "api.github.com:443", ServedBy: []string{"pep"}}, "one hostname"}, + {"path", &EgressDestination{Name: "api.github.com/v3", ServedBy: []string{"pep"}}, "one hostname"}, + {"target_url with credential", &EgressDestination{Name: "api.github.com", TargetUrl: "https://:tok@api.github.com", ServedBy: []string{"pep"}}, "must not carry a credential"}, + {"target_url scheme", &EgressDestination{Name: "api.github.com", TargetUrl: "ftp://api.github.com", ServedBy: []string{"pep"}}, "http or https"}, + {"credential is a path", &EgressDestination{Name: "api.github.com", Credential: "/etc/passwd", ServedBy: []string{"pep"}}, "not a path"}, + {"no served_by", &EgressDestination{Name: "api.github.com"}, "served_by"}, + {"unknown role", &EgressDestination{Name: "api.github.com", ServedBy: []string{"nobody"}}, "neither a role"}, + {"bad label value", &EgressDestination{Name: "api.github.com", ServedBy: []string{"site="}}, "label value"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateEgressDestination(tt.d, roles) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestEgressServedBy(t *testing.T) { + d := &EgressDestination{Name: "api.github.com", ServedBy: []string{"pep", "site=eu"}} + if !EgressServedBy(d, []string{"sam:role:node", "pep"}, nil) { + t.Error("role match missed") + } + if !EgressServedBy(d, []string{"sam:role:node"}, map[string]string{"site": "eu"}) { + t.Error("label match missed") + } + if EgressServedBy(d, []string{"sam:role:node"}, map[string]string{"site": "us"}) { + t.Error("matched a node it does not select") + } + if EgressTargetURL(d) != "https://api.github.com" { + t.Errorf("default target = %q", EgressTargetURL(d)) + } + d.TargetUrl = "http://localhost:9" + if EgressTargetURL(d) != "http://localhost:9" { + t.Errorf("explicit target = %q", EgressTargetURL(d)) + } +} + +func TestBuildEgressServingRules(t *testing.T) { + rules := BuildEgressServingRules([]*EgressDestination{ + {Name: "api.github.com", ServedBy: []string{"pep", "site=eu"}}, + nil, + {Name: "", ServedBy: []string{"pep"}}, + }) + texts := PolicyRuleTexts(rules) + want := []string{ + `granted_service_exact("egress", "api.github.com") <- role("pep")`, + `granted_service_exact("egress", "api.github.com") <- label("site", "eu")`, + } + if !slices.Equal(texts, want) { + t.Errorf("rules = %v, want %v", texts, want) + } + if _, err := ParseDatalogRules(texts); err != nil { + t.Fatalf("rendered rules do not parse back: %v", err) + } +} diff --git a/api/http_grants.go b/api/http_grants.go new file mode 100644 index 00000000..756f2cd9 --- /dev/null +++ b/api/http_grants.go @@ -0,0 +1,192 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strings" + + "github.com/biscuit-auth/biscuit-go/v2" +) + +// httpMethodSyntax is the token form of an HTTP method, uppercase as the +// registered methods are written. A method is compared byte for byte with +// the request, so the case has to be fixed here. +var httpMethodSyntax = regexp.MustCompile(`^[A-Z][A-Z0-9-]{0,31}$`) + +// HTTPGrantKey returns the fact a narrowed grant is minted as and the key its +// method and path facts are keyed by, for one allowed_services entry. The key +// is the term the plain granted_service_* fact would carry, so +// BaselineHTTPRules can derive that fact from it. +func HTTPGrantKey(service string) (factName, svcType, key string) { + plain := BuildServiceDatalogFact(service) + svcType, svcName := ParseServiceTarget(service) + switch plain.Name { + case FactGrantedServiceAllTypes: + return FactHTTPGrantedServiceAllTypes, "*", "*" + case FactGrantedServiceAll: + return FactHTTPGrantedServiceAll, svcType, "*" + case FactGrantedServiceSuffix: + return FactHTTPGrantedServiceSuffix, svcType, svcName[1:] + case FactGrantedServicePrefix: + return FactHTTPGrantedServicePrefix, svcType, svcName[:len(svcName)-1] + default: + return FactHTTPGrantedServiceExact, svcType, svcName + } +} + +// ValidateHTTPGrant checks one PolicyRole.http entry against the role's +// allowed_services: the entry must narrow a grant the role makes, written the +// same way, and its methods and paths must be well-formed. +func ValidateHTTPGrant(g *HTTPGrant, allowedServices []string) error { + if g == nil { + return fmt.Errorf("http entry is nil") + } + if g.GetService() == "" { + return fmt.Errorf("http entry has no service") + } + if !slices.Contains(allowedServices, g.GetService()) { + return fmt.Errorf("http entry %q does not name one of the role's allowed_services", g.GetService()) + } + if err := ValidateServiceFormat(g.GetService()); err != nil { + return err + } + if len(g.GetMethods()) == 0 && len(g.GetPaths()) == 0 { + return fmt.Errorf("http entry %q narrows nothing: set methods, paths or both, or remove the entry", g.GetService()) + } + for _, m := range g.GetMethods() { + if !httpMethodSyntax.MatchString(m) { + return fmt.Errorf("http entry %q: method %q must be an uppercase HTTP method such as \"GET\"", g.GetService(), m) + } + } + for _, p := range g.GetPaths() { + if err := validateHTTPGrantPath(p); err != nil { + return fmt.Errorf("http entry %q: %w", g.GetService(), err) + } + } + return nil +} + +// validateHTTPGrantPath accepts "/exact" or "/prefix/*". The path is matched +// against path($p) as the backend sees it, so it carries no query and no +// dot segment, and a wildcard is only meaningful at the end. +func validateHTTPGrantPath(p string) error { + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("path %q must start with \"/\"", p) + } + if strings.ContainsAny(p, "?#") { + return fmt.Errorf("path %q must not carry a query or a fragment", p) + } + trimmed := strings.TrimSuffix(p, "*") + if strings.Contains(trimmed, "*") { + return fmt.Errorf("path %q: \"*\" is only allowed at the end, as in \"/v2/*\"", p) + } + for _, seg := range strings.Split(trimmed, "/") { + if seg == "." || seg == ".." { + return fmt.Errorf("path %q must not contain a dot segment", p) + } + } + return nil +} + +// BuildHTTPGrantFacts compiles one narrowed grant into the facts minted into +// the holder's credential: the http_granted_service_* fact for the entry, +// then one fact per axis. Methods and exact paths travel as one Set each; +// each prefix is its own fact, because starts_with has no set form. +func BuildHTTPGrantFacts(g *HTTPGrant) []biscuit.Fact { + factName, svcType, key := HTTPGrantKey(g.GetService()) + keyTerms := []biscuit.Term{biscuit.String(svcType), biscuit.String(key)} + + var facts []biscuit.Fact + switch factName { + case FactHTTPGrantedServiceAllTypes: + facts = append(facts, MarkerFact(factName)) + case FactHTTPGrantedServiceAll: + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{Name: factName, IDs: []biscuit.Term{biscuit.String(svcType)}}}) + default: + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{Name: factName, IDs: keyTerms}}) + } + + if len(g.GetMethods()) == 0 { + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedMethodAny, IDs: keyTerms}}) + } else { + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedMethod, + IDs: append(slices.Clone(keyTerms), stringSet(g.GetMethods())), + }}) + } + + var exact, prefixes []string + for _, p := range g.GetPaths() { + if strings.HasSuffix(p, "*") { + prefixes = append(prefixes, strings.TrimSuffix(p, "*")) + } else { + exact = append(exact, p) + } + } + if len(exact) == 0 && len(prefixes) == 0 { + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedPathAny, IDs: keyTerms}}) + } + if len(exact) > 0 { + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedPathExact, + IDs: append(slices.Clone(keyTerms), stringSet(exact)), + }}) + } + sort.Strings(prefixes) + for _, prefix := range prefixes { + facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedPathPrefix, + IDs: append(slices.Clone(keyTerms), biscuit.String(prefix)), + }}) + } + return facts +} + +// stringSet builds a sorted, deduplicated Biscuit Set of strings. +func stringSet(values []string) biscuit.Set { + sorted := slices.Clone(values) + sort.Strings(sorted) + sorted = slices.Compact(sorted) + set := make(biscuit.Set, 0, len(sorted)) + for _, v := range sorted { + set = append(set, biscuit.String(v)) + } + return set +} + +// SplitHTTPGrants separates a role's allowed_services into the entries minted +// as plain grants and the entries narrowed by PolicyRole.http. A narrowed entry +// is withheld from the plain list: it exists only as its http_granted_service_* +// fact, and the request has to earn the plain fact through BaselineHTTPRules. +func SplitHTTPGrants(role *PolicyRole) (plain []string, narrowed []*HTTPGrant) { + narrowedByService := make(map[string]bool, len(role.GetHttp())) + for _, g := range role.GetHttp() { + if g == nil || g.GetService() == "" { + continue + } + narrowedByService[g.GetService()] = true + narrowed = append(narrowed, g) + } + for _, svc := range role.GetAllowedServices() { + if !narrowedByService[svc] { + plain = append(plain, svc) + } + } + return plain, narrowed +} diff --git a/api/http_grants_test.go b/api/http_grants_test.go new file mode 100644 index 00000000..7bded6f8 --- /dev/null +++ b/api/http_grants_test.go @@ -0,0 +1,289 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/biscuit-auth/biscuit-go/v2/datalog" +) + +// authorizeHTTP evaluates the baseline policies plus BaselineHTTPRules for a +// token minted with the given facts, against a request on target with the +// given method and path. An empty method leaves the request facts out, as a +// stream that carries no HTTP request does. +func authorizeHTTP(t *testing.T, tokenFacts []biscuit.Fact, target, method, path string) error { + t.Helper() + pub, priv := makeKeyPair(t) + builder := biscuit.NewBuilder(priv) + for _, f := range tokenFacts { + if err := builder.AddAuthorityFact(f); err != nil { + t.Fatalf("AddAuthorityFact: %v", err) + } + } + tok, err := builder.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + authorizer, err := tok.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(5*time.Second))) + if err != nil { + t.Fatalf("Authorizer: %v", err) + } + opType, opName := ParseServiceTarget(target) + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{Name: FactService, IDs: []biscuit.Term{biscuit.String(opType), biscuit.String(opName)}}}) + if method != "" { + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{Name: FactMethod, IDs: []biscuit.Term{biscuit.String(method)}}}) + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{Name: FactPath, IDs: []biscuit.Term{biscuit.String(path)}}}) + } + for _, p := range BaselinePolicies { + authorizer.AddPolicy(p) + } + for _, r := range BaselineHTTPRules { + authorizer.AddRule(r) + } + return authorizer.Authorize() +} + +func TestHTTPGrantNarrowsServiceGrant(t *testing.T) { + narrowed := BuildHTTPGrantFacts(&HTTPGrant{ + Service: "egress://api.github.com", + Methods: []string{"GET", "HEAD"}, + Paths: []string{"/repos/acme/*", "/user"}, + }) + + tests := []struct { + name string + facts []biscuit.Fact + target string + method string + path string + expectAllow bool + }{ + {"allowed method under the prefix", narrowed, "egress://api.github.com", "GET", "/repos/acme/dubbing/pulls", true}, + {"allowed method on the exact path", narrowed, "egress://api.github.com", "HEAD", "/user", true}, + {"method outside the grant", narrowed, "egress://api.github.com", "POST", "/repos/acme/dubbing/pulls", false}, + {"path outside the grant", narrowed, "egress://api.github.com", "GET", "/repos/other/x", false}, + {"the prefix itself is not the exact path", narrowed, "egress://api.github.com", "GET", "/userinfo", false}, + {"another service is not granted at all", narrowed, "egress://api.other.com", "GET", "/user", false}, + // A tunnel is CONNECT with an empty path: neither axis matches. + {"a tunnel fails closed", narrowed, "egress://api.github.com", "CONNECT", "", false}, + // No method() fact at all: the positive rules derive nothing. + {"a request without HTTP facts fails closed", narrowed, "egress://api.github.com", "", "", false}, + { + name: "methods narrowed, any path", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "mcp://tools", Methods: []string{"GET"}}), + target: "mcp://tools", + method: "GET", + path: "/anything/at/all", + expectAllow: true, + }, + { + name: "paths narrowed, any method", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "mcp://tools", Paths: []string{"/mcp"}}), + target: "mcp://tools", + method: "POST", + path: "/mcp", + expectAllow: true, + }, + { + name: "paths narrowed fails closed on a tunnel", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "mcp://tools", Paths: []string{"/mcp"}}), + target: "mcp://tools", + method: "CONNECT", + path: "", + expectAllow: false, + }, + { + name: "suffix pattern narrowed", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "egress://*.internal.example.com", Methods: []string{"GET"}}), + target: "egress://mam.internal.example.com", + method: "GET", + path: "/v2", + expectAllow: true, + }, + { + name: "suffix pattern narrowed, wrong method", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "egress://*.internal.example.com", Methods: []string{"GET"}}), + target: "egress://mam.internal.example.com", + method: "PUT", + path: "/v2", + expectAllow: false, + }, + { + name: "prefix pattern narrowed", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "mcp://calc.*", Paths: []string{"/mcp"}}), + target: "mcp://calc.service.internal", + method: "POST", + path: "/mcp", + expectAllow: true, + }, + { + name: "type wildcard narrowed", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "egress://*", Methods: []string{"GET"}}), + target: "egress://anything.example", + method: "GET", + path: "/", + expectAllow: true, + }, + { + name: "type wildcard narrowed does not leak to another type", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "egress://*", Methods: []string{"GET"}}), + target: "mcp://anything", + method: "GET", + path: "/", + expectAllow: false, + }, + { + name: "global wildcard narrowed", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "*", Methods: []string{"GET"}}), + target: "inference://anything", + method: "GET", + path: "/v1/models", + expectAllow: true, + }, + { + name: "global wildcard narrowed, wrong method", + facts: BuildHTTPGrantFacts(&HTTPGrant{Service: "*", Methods: []string{"GET"}}), + target: "inference://anything", + method: "POST", + path: "/v1/chat/completions", + expectAllow: false, + }, + { + // Union semantics: a plain grant from another role is not narrowed. + name: "a plain grant alongside a narrowed one is still plain", + facts: append(slices.Clone(narrowed), + biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String("egress"), biscuit.String("api.github.com")}}}), + target: "egress://api.github.com", + method: "DELETE", + path: "/repos/other", + expectAllow: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := authorizeHTTP(t, tt.facts, tt.target, tt.method, tt.path) + if tt.expectAllow && err != nil { + t.Errorf("expected allow, got %v", err) + } + if !tt.expectAllow && err == nil { + t.Error("expected deny, got allow") + } + }) + } +} + +func TestHTTPGrantKey(t *testing.T) { + tests := []struct { + service string + wantFact, want string + wantType string + }{ + {"egress://api.github.com", FactHTTPGrantedServiceExact, "api.github.com", "egress"}, + {"egress://*.internal.example.com", FactHTTPGrantedServiceSuffix, ".internal.example.com", "egress"}, + {"mcp://calc.*", FactHTTPGrantedServicePrefix, "calc.", "mcp"}, + {"egress://*", FactHTTPGrantedServiceAll, "*", "egress"}, + {"*", FactHTTPGrantedServiceAllTypes, "*", "*"}, + } + for _, tt := range tests { + fact, typ, key := HTTPGrantKey(tt.service) + if fact != tt.wantFact || typ != tt.wantType || key != tt.want { + t.Errorf("HTTPGrantKey(%q) = (%s, %s, %s), want (%s, %s, %s)", tt.service, fact, typ, key, tt.wantFact, tt.wantType, tt.want) + } + } +} + +func TestSplitHTTPGrants(t *testing.T) { + role := &PolicyRole{ + AllowedServices: []string{"mcp://a", "egress://b", "inference://*"}, + Http: []*HTTPGrant{{Service: "egress://b", Methods: []string{"GET"}}}, + } + plain, narrowed := SplitHTTPGrants(role) + if want := []string{"mcp://a", "inference://*"}; !slices.Equal(plain, want) { + t.Errorf("plain = %v, want %v", plain, want) + } + if len(narrowed) != 1 || narrowed[0].Service != "egress://b" { + t.Errorf("narrowed = %v, want the egress://b entry", narrowed) + } +} + +func TestBuildPolicyRulesRendersHTTPGrants(t *testing.T) { + rules, warnings := BuildPolicyRules([]*PolicyRole{{ + Name: "contractor", + AllowedServices: []string{"mcp://tools", "egress://mam.internal.example.com"}, + Http: []*HTTPGrant{{Service: "egress://mam.internal.example.com", Methods: []string{"GET"}, Paths: []string{"/v2/public/*"}}}, + }}, nil) + if len(warnings) != 0 { + t.Fatalf("warnings: %v", warnings) + } + texts := PolicyRuleTexts(rules) + for _, want := range []string{ + `granted_service_set("mcp", ["tools"]) <- role("contractor")`, + `http_granted_service_exact("egress", "mam.internal.example.com") <- role("contractor")`, + `granted_method("egress", "mam.internal.example.com", ["GET"]) <- role("contractor")`, + `granted_path_prefix("egress", "mam.internal.example.com", "/v2/public/") <- role("contractor")`, + } { + if !slices.Contains(texts, want) { + t.Errorf("rules lack %q; got:\n%s", want, strings.Join(texts, "\n")) + } + } + for _, text := range texts { + if strings.HasPrefix(text, `granted_service_exact("egress"`) || strings.HasPrefix(text, `granted_service_set("egress"`) { + t.Errorf("narrowed entry rendered as a plain grant: %s", text) + } + } + // The rendered text is what every other implementation parses. + if _, err := ParseDatalogRules(texts); err != nil { + t.Fatalf("rendered rules do not parse back: %v", err) + } +} + +func TestValidateHTTPGrant(t *testing.T) { + allowed := []string{"egress://api.github.com", "mcp://tools"} + tests := []struct { + name string + grant *HTTPGrant + wantErr string + }{ + {"valid", &HTTPGrant{Service: "egress://api.github.com", Methods: []string{"GET", "HEAD"}, Paths: []string{"/user", "/repos/*"}}, ""}, + {"valid with one axis", &HTTPGrant{Service: "mcp://tools", Paths: []string{"/mcp"}}, ""}, + {"narrows nothing", &HTTPGrant{Service: "mcp://tools"}, "narrows nothing"}, + {"not one of allowed_services", &HTTPGrant{Service: "egress://other.example"}, "does not name one of the role's allowed_services"}, + {"empty service", &HTTPGrant{}, "no service"}, + {"lowercase method", &HTTPGrant{Service: "mcp://tools", Methods: []string{"get"}}, "uppercase HTTP method"}, + {"relative path", &HTTPGrant{Service: "mcp://tools", Paths: []string{"user"}}, `must start with "/"`}, + {"query in path", &HTTPGrant{Service: "mcp://tools", Paths: []string{"/user?x=1"}}, "query"}, + {"wildcard in the middle", &HTTPGrant{Service: "mcp://tools", Paths: []string{"/a/*/b"}}, "only allowed at the end"}, + {"dot segment", &HTTPGrant{Service: "mcp://tools", Paths: []string{"/a/../b"}}, "dot segment"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateHTTPGrant(tt.grant, allowed) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want it to contain %q", err, tt.wantErr) + } + }) + } +} diff --git a/api/names.go b/api/names.go index 6fda3d3f..720b627f 100644 --- a/api/names.go +++ b/api/names.go @@ -131,9 +131,15 @@ func ParseMeshHost(host string) (serviceURI string, err error) { } name, typeStr := rest[:dot], rest[dot+1:] - if _, err := ParseServiceType(typeStr); err != nil { + serviceType, err := ParseServiceType(typeStr) + if err != nil { return "", fmt.Errorf("mesh host %q: %w", host, err) } + // An egress destination is addressed by its own name; projecting it into + // the zone would give one destination two names on the boundary. + if serviceType == ServiceType_SERVICE_TYPE_EGRESS { + return "", fmt.Errorf("mesh host %q: egress destinations have no %s name; connect to %q itself", host, MeshZone, name) + } uri := typeStr + "://" + name if err := ValidateServiceFormat(uri); err != nil { @@ -145,6 +151,9 @@ func ParseMeshHost(host string) (serviceURI string, err error) { // MeshHost is the inverse of ParseMeshHost: it renders the hostname a sandboxed // agent should connect to in order to reach the given service. func MeshHost(t ServiceType, serviceName string) (string, error) { + if t == ServiceType_SERVICE_TYPE_EGRESS { + return "", fmt.Errorf("egress destination %q has no %s name; it is reached by its own name", serviceName, MeshZone) + } typeStr, err := ServiceTypeToString(t) if err != nil { return "", err diff --git a/api/network.go b/api/network.go index 38ce4660..2a2b541e 100644 --- a/api/network.go +++ b/api/network.go @@ -212,6 +212,11 @@ const ( // InferenceServicePrefix is the scheme prefix for LLM Inference services. // Fully qualified inference services use the URI format: inference:// InferenceServicePrefix = "inference://" + + // EgressServicePrefix is the scheme prefix for destinations outside the + // mesh, served by a node that enforces policy on them. The name is the + // destination hostname: egress://api.github.com + EgressServicePrefix = "egress://" ) // ============================================================================ @@ -227,6 +232,9 @@ const ( // ServiceTypeStringA2A is the string identifier for A2A (Agent2Agent) services. ServiceTypeStringA2A = "a2a" + + // ServiceTypeStringEgress is the string identifier for egress destinations. + ServiceTypeStringEgress = "egress" ) // ParseServiceType converts a string identifier (e.g. from JSON or REST) to the ServiceType protobuf enum. @@ -238,6 +246,8 @@ func ParseServiceType(s string) (ServiceType, error) { return ServiceType_SERVICE_TYPE_INFERENCE, nil case ServiceTypeStringA2A: return ServiceType_SERVICE_TYPE_A2A, nil + case ServiceTypeStringEgress: + return ServiceType_SERVICE_TYPE_EGRESS, nil default: return ServiceType_SERVICE_TYPE_UNSPECIFIED, fmt.Errorf("invalid service type: %s", s) } @@ -252,6 +262,8 @@ func ServiceTypeToString(t ServiceType) (string, error) { return ServiceTypeStringInference, nil case ServiceType_SERVICE_TYPE_A2A: return ServiceTypeStringA2A, nil + case ServiceType_SERVICE_TYPE_EGRESS: + return ServiceTypeStringEgress, nil default: return "", fmt.Errorf("invalid or unspecified service type") } diff --git a/api/policy_rules.go b/api/policy_rules.go index 24ce1b31..22384ffd 100644 --- a/api/policy_rules.go +++ b/api/policy_rules.go @@ -86,9 +86,15 @@ func BuildPolicyRules(roles []*PolicyRole, bindings []*PolicyBinding) (rules []P roleName := role.Name fromRole := biscuit.Predicate{Name: FactRole, IDs: []biscuit.Term{biscuit.String(roleName)}} - for _, fact := range BuildServiceDatalogFacts(role.AllowedServices) { + plainServices, narrowed := SplitHTTPGrants(role) + for _, fact := range BuildServiceDatalogFacts(plainServices) { add(fact.Predicate, fromRole) } + for _, g := range narrowed { + for _, fact := range BuildHTTPGrantFacts(g) { + add(fact.Predicate, fromRole) + } + } hasUnrestricted := false var nonWildcardTargets []string diff --git a/api/sam.pb.go b/api/sam.pb.go index 291e6373..4d830601 100644 --- a/api/sam.pb.go +++ b/api/sam.pb.go @@ -95,6 +95,12 @@ const ( ServiceType_SERVICE_TYPE_MCP ServiceType = 1 ServiceType_SERVICE_TYPE_INFERENCE ServiceType = 2 ServiceType_SERVICE_TYPE_A2A ServiceType = 3 + // A destination outside the mesh, reached through a node that enforces + // policy on it. The service name is the destination hostname, so a grant + // reads egress://api.github.com and the request fact + // service("egress", "api.github.com"). Egress names have no .sam.alt form: + // a sandboxed agent connects to the destination name itself. + ServiceType_SERVICE_TYPE_EGRESS ServiceType = 4 ) // Enum value maps for ServiceType. @@ -104,12 +110,14 @@ var ( 1: "SERVICE_TYPE_MCP", 2: "SERVICE_TYPE_INFERENCE", 3: "SERVICE_TYPE_A2A", + 4: "SERVICE_TYPE_EGRESS", } ServiceType_value = map[string]int32{ "SERVICE_TYPE_UNSPECIFIED": 0, "SERVICE_TYPE_MCP": 1, "SERVICE_TYPE_INFERENCE": 2, "SERVICE_TYPE_A2A": 3, + "SERVICE_TYPE_EGRESS": 4, } ) @@ -1403,6 +1411,8 @@ type PolicyRole struct { // or "key=value". A node declares its own labels, so this is what turns a // declaration into something the control plane is willing to sign. AllowedLabels []string `protobuf:"bytes,6,rep,name=allowed_labels,json=allowedLabels,proto3" json:"allowed_labels,omitempty"` + // HTTP narrowing of allowed_services entries; see HTTPGrant. + Http []*HTTPGrant `protobuf:"bytes,7,rep,name=http,proto3" json:"http,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1479,6 +1489,168 @@ func (x *PolicyRole) GetAllowedLabels() []string { return nil } +func (x *PolicyRole) GetHttp() []*HTTPGrant { + if x != nil { + return x.Http + } + return nil +} + +// HTTPGrant narrows one allowed_services entry to HTTP methods and paths. +// The control plane compiles it into granted_method and granted_path_* facts +// in the holder's credential and withholds the plain service grant for that +// entry. The baseline rules derive the service grant only for a request +// whose method($m) and path($p) facts match, so a request that carries no +// HTTP method (a tunnel, a non-HTTP stream) does not match a narrowed entry. +type HTTPGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + // One of the role's allowed_services entries, written identically. + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + // Methods the holder may use, e.g. "GET", "HEAD". Empty means any method. + Methods []string `protobuf:"bytes,2,rep,name=methods,proto3" json:"methods,omitempty"` + // Paths the holder may request, as the backend sees them: "/user" matches + // that path only, "/v2/public/*" matches every path under the prefix. + // Empty means any path. At least one of methods and paths must be set. + Paths []string `protobuf:"bytes,3,rep,name=paths,proto3" json:"paths,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HTTPGrant) Reset() { + *x = HTTPGrant{} + mi := &file_api_sam_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HTTPGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HTTPGrant) ProtoMessage() {} + +func (x *HTTPGrant) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HTTPGrant.ProtoReflect.Descriptor instead. +func (*HTTPGrant) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{16} +} + +func (x *HTTPGrant) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *HTTPGrant) GetMethods() []string { + if x != nil { + return x.Methods + } + return nil +} + +func (x *HTTPGrant) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + +// EgressDestination is a destination outside the mesh that selected nodes +// serve as egress://. It is part of the policy document the admin +// writes; a node receives the destinations that select it at GET /egress +// and serves them without configuration of its own. +type EgressDestination struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The destination hostname, lowercase, without a port or a path. It is the + // service name in grants (egress://) and the DHT key. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Where the serving node forwards requests. Optional; https:// when + // empty. Must not carry a credential. + TargetUrl string `protobuf:"bytes,2,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + // Name of the credential the serving node presents upstream, resolved by + // the node from its secrets directory. Never a value: secret material does + // not travel through this API. + Credential string `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"` + // Role names or key=value labels selecting the nodes that serve this + // destination. A node matches when any entry names one of its roles or + // labels. The control plane also grants the destination to the selected + // nodes, so the serving node authorizes local requests with its own + // credential; other callers need the grant on their own role. + ServedBy []string `protobuf:"bytes,4,rep,name=served_by,json=servedBy,proto3" json:"served_by,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressDestination) Reset() { + *x = EgressDestination{} + mi := &file_api_sam_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressDestination) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressDestination) ProtoMessage() {} + +func (x *EgressDestination) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EgressDestination.ProtoReflect.Descriptor instead. +func (*EgressDestination) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{17} +} + +func (x *EgressDestination) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EgressDestination) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *EgressDestination) GetCredential() string { + if x != nil { + return x.Credential + } + return "" +} + +func (x *EgressDestination) GetServedBy() []string { + if x != nil { + return x.ServedBy + } + return nil +} + type PolicyBinding struct { state protoimpl.MessageState `protogen:"open.v1"` Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` @@ -1489,7 +1661,7 @@ type PolicyBinding struct { func (x *PolicyBinding) Reset() { *x = PolicyBinding{} - mi := &file_api_sam_proto_msgTypes[16] + mi := &file_api_sam_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1501,7 +1673,7 @@ func (x *PolicyBinding) String() string { func (*PolicyBinding) ProtoMessage() {} func (x *PolicyBinding) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[16] + mi := &file_api_sam_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1514,7 +1686,7 @@ func (x *PolicyBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyBinding.ProtoReflect.Descriptor instead. func (*PolicyBinding) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{16} + return file_api_sam_proto_rawDescGZIP(), []int{18} } func (x *PolicyBinding) GetRole() string { @@ -1539,13 +1711,14 @@ type PolicyConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Roles []*PolicyRole `protobuf:"bytes,1,rep,name=roles,proto3" json:"roles,omitempty"` Bindings []*PolicyBinding `protobuf:"bytes,2,rep,name=bindings,proto3" json:"bindings,omitempty"` + Egress []*EgressDestination `protobuf:"bytes,3,rep,name=egress,proto3" json:"egress,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PolicyConfig) Reset() { *x = PolicyConfig{} - mi := &file_api_sam_proto_msgTypes[17] + mi := &file_api_sam_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1557,7 +1730,7 @@ func (x *PolicyConfig) String() string { func (*PolicyConfig) ProtoMessage() {} func (x *PolicyConfig) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[17] + mi := &file_api_sam_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1570,7 +1743,7 @@ func (x *PolicyConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfig.ProtoReflect.Descriptor instead. func (*PolicyConfig) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{17} + return file_api_sam_proto_rawDescGZIP(), []int{19} } func (x *PolicyConfig) GetRoles() []*PolicyRole { @@ -1587,6 +1760,13 @@ func (x *PolicyConfig) GetBindings() []*PolicyBinding { return nil } +func (x *PolicyConfig) GetEgress() []*EgressDestination { + if x != nil { + return x.Egress + } + return nil +} + type PolicyConfigGetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1595,7 +1775,7 @@ type PolicyConfigGetRequest struct { func (x *PolicyConfigGetRequest) Reset() { *x = PolicyConfigGetRequest{} - mi := &file_api_sam_proto_msgTypes[18] + mi := &file_api_sam_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1607,7 +1787,7 @@ func (x *PolicyConfigGetRequest) String() string { func (*PolicyConfigGetRequest) ProtoMessage() {} func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[18] + mi := &file_api_sam_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1620,7 +1800,7 @@ func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetRequest.ProtoReflect.Descriptor instead. func (*PolicyConfigGetRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{18} + return file_api_sam_proto_rawDescGZIP(), []int{20} } // PolicyConfigGetResponse answers GET /policies for a mesh member holding a @@ -1636,7 +1816,7 @@ type PolicyConfigGetResponse struct { func (x *PolicyConfigGetResponse) Reset() { *x = PolicyConfigGetResponse{} - mi := &file_api_sam_proto_msgTypes[19] + mi := &file_api_sam_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1648,7 +1828,7 @@ func (x *PolicyConfigGetResponse) String() string { func (*PolicyConfigGetResponse) ProtoMessage() {} func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[19] + mi := &file_api_sam_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1661,7 +1841,7 @@ func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigGetResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{19} + return file_api_sam_proto_rawDescGZIP(), []int{21} } func (x *PolicyConfigGetResponse) GetDatalogRules() []string { @@ -1681,7 +1861,7 @@ type PolicyConfigUpdateResponse struct { func (x *PolicyConfigUpdateResponse) Reset() { *x = PolicyConfigUpdateResponse{} - mi := &file_api_sam_proto_msgTypes[20] + mi := &file_api_sam_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1693,7 +1873,7 @@ func (x *PolicyConfigUpdateResponse) String() string { func (*PolicyConfigUpdateResponse) ProtoMessage() {} func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[20] + mi := &file_api_sam_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1706,7 +1886,7 @@ func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigUpdateResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigUpdateResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{20} + return file_api_sam_proto_rawDescGZIP(), []int{22} } func (x *PolicyConfigUpdateResponse) GetSuccess() bool { @@ -1723,6 +1903,90 @@ func (x *PolicyConfigUpdateResponse) GetError() string { return "" } +type EgressAssignmentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressAssignmentsRequest) Reset() { + *x = EgressAssignmentsRequest{} + mi := &file_api_sam_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressAssignmentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressAssignmentsRequest) ProtoMessage() {} + +func (x *EgressAssignmentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EgressAssignmentsRequest.ProtoReflect.Descriptor instead. +func (*EgressAssignmentsRequest) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{23} +} + +// EgressAssignmentsResponse answers GET /egress for a mesh member holding a +// biscuit: the destinations whose served_by selects that node. It is a +// separate endpoint from GET /policies so that a node predating it keeps +// syncing rules unchanged. +type EgressAssignmentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Egress []*EgressDestination `protobuf:"bytes,1,rep,name=egress,proto3" json:"egress,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressAssignmentsResponse) Reset() { + *x = EgressAssignmentsResponse{} + mi := &file_api_sam_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressAssignmentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressAssignmentsResponse) ProtoMessage() {} + +func (x *EgressAssignmentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EgressAssignmentsResponse.ProtoReflect.Descriptor instead. +func (*EgressAssignmentsResponse) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{24} +} + +func (x *EgressAssignmentsResponse) GetEgress() []*EgressDestination { + if x != nil { + return x.Egress + } + return nil +} + type KeysResponse struct { state protoimpl.MessageState `protogen:"open.v1"` PublicKeys [][]byte `protobuf:"bytes,1,rep,name=public_keys,json=publicKeys,proto3" json:"public_keys,omitempty"` @@ -1740,7 +2004,7 @@ type KeysResponse struct { func (x *KeysResponse) Reset() { *x = KeysResponse{} - mi := &file_api_sam_proto_msgTypes[21] + mi := &file_api_sam_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1752,7 +2016,7 @@ func (x *KeysResponse) String() string { func (*KeysResponse) ProtoMessage() {} func (x *KeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[21] + mi := &file_api_sam_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1765,7 +2029,7 @@ func (x *KeysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. func (*KeysResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{21} + return file_api_sam_proto_rawDescGZIP(), []int{25} } func (x *KeysResponse) GetPublicKeys() [][]byte { @@ -1813,7 +2077,7 @@ type TokenRefreshRequest struct { func (x *TokenRefreshRequest) Reset() { *x = TokenRefreshRequest{} - mi := &file_api_sam_proto_msgTypes[22] + mi := &file_api_sam_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1825,7 +2089,7 @@ func (x *TokenRefreshRequest) String() string { func (*TokenRefreshRequest) ProtoMessage() {} func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[22] + mi := &file_api_sam_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1838,7 +2102,7 @@ func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshRequest.ProtoReflect.Descriptor instead. func (*TokenRefreshRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{22} + return file_api_sam_proto_rawDescGZIP(), []int{26} } func (x *TokenRefreshRequest) GetChallengeSignature() []byte { @@ -1873,7 +2137,7 @@ type TokenRefreshResponse struct { func (x *TokenRefreshResponse) Reset() { *x = TokenRefreshResponse{} - mi := &file_api_sam_proto_msgTypes[23] + mi := &file_api_sam_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1885,7 +2149,7 @@ func (x *TokenRefreshResponse) String() string { func (*TokenRefreshResponse) ProtoMessage() {} func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[23] + mi := &file_api_sam_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1898,7 +2162,7 @@ func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshResponse.ProtoReflect.Descriptor instead. func (*TokenRefreshResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{23} + return file_api_sam_proto_rawDescGZIP(), []int{27} } func (x *TokenRefreshResponse) GetBiscuitToken() []byte { @@ -1935,7 +2199,7 @@ type NodeCatalogReport struct { func (x *NodeCatalogReport) Reset() { *x = NodeCatalogReport{} - mi := &file_api_sam_proto_msgTypes[24] + mi := &file_api_sam_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1947,7 +2211,7 @@ func (x *NodeCatalogReport) String() string { func (*NodeCatalogReport) ProtoMessage() {} func (x *NodeCatalogReport) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[24] + mi := &file_api_sam_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1960,7 +2224,7 @@ func (x *NodeCatalogReport) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeCatalogReport.ProtoReflect.Descriptor instead. func (*NodeCatalogReport) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{24} + return file_api_sam_proto_rawDescGZIP(), []int{28} } func (x *NodeCatalogReport) GetServices() []*ServiceInfo { @@ -1979,7 +2243,7 @@ type TokenRevokeRequest struct { func (x *TokenRevokeRequest) Reset() { *x = TokenRevokeRequest{} - mi := &file_api_sam_proto_msgTypes[25] + mi := &file_api_sam_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1991,7 +2255,7 @@ func (x *TokenRevokeRequest) String() string { func (*TokenRevokeRequest) ProtoMessage() {} func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[25] + mi := &file_api_sam_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2004,7 +2268,7 @@ func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeRequest.ProtoReflect.Descriptor instead. func (*TokenRevokeRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{25} + return file_api_sam_proto_rawDescGZIP(), []int{29} } func (x *TokenRevokeRequest) GetPeerId() string { @@ -2024,7 +2288,7 @@ type TokenRevokeResponse struct { func (x *TokenRevokeResponse) Reset() { *x = TokenRevokeResponse{} - mi := &file_api_sam_proto_msgTypes[26] + mi := &file_api_sam_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2036,7 +2300,7 @@ func (x *TokenRevokeResponse) String() string { func (*TokenRevokeResponse) ProtoMessage() {} func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[26] + mi := &file_api_sam_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2049,7 +2313,7 @@ func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeResponse.ProtoReflect.Descriptor instead. func (*TokenRevokeResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{26} + return file_api_sam_proto_rawDescGZIP(), []int{30} } func (x *TokenRevokeResponse) GetSuccess() bool { @@ -2080,7 +2344,7 @@ type AgentSecret struct { func (x *AgentSecret) Reset() { *x = AgentSecret{} - mi := &file_api_sam_proto_msgTypes[27] + mi := &file_api_sam_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2092,7 +2356,7 @@ func (x *AgentSecret) String() string { func (*AgentSecret) ProtoMessage() {} func (x *AgentSecret) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[27] + mi := &file_api_sam_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2105,7 +2369,7 @@ func (x *AgentSecret) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentSecret.ProtoReflect.Descriptor instead. func (*AgentSecret) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{27} + return file_api_sam_proto_rawDescGZIP(), []int{31} } func (x *AgentSecret) GetHost() string { @@ -2148,7 +2412,7 @@ type AgentEgress struct { func (x *AgentEgress) Reset() { *x = AgentEgress{} - mi := &file_api_sam_proto_msgTypes[28] + mi := &file_api_sam_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2160,7 +2424,7 @@ func (x *AgentEgress) String() string { func (*AgentEgress) ProtoMessage() {} func (x *AgentEgress) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[28] + mi := &file_api_sam_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2173,7 +2437,7 @@ func (x *AgentEgress) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentEgress.ProtoReflect.Descriptor instead. func (*AgentEgress) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{28} + return file_api_sam_proto_rawDescGZIP(), []int{32} } func (x *AgentEgress) GetAllow() []string { @@ -2205,7 +2469,7 @@ type AgentIngress struct { func (x *AgentIngress) Reset() { *x = AgentIngress{} - mi := &file_api_sam_proto_msgTypes[29] + mi := &file_api_sam_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2217,7 +2481,7 @@ func (x *AgentIngress) String() string { func (*AgentIngress) ProtoMessage() {} func (x *AgentIngress) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[29] + mi := &file_api_sam_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2230,7 +2494,7 @@ func (x *AgentIngress) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentIngress.ProtoReflect.Descriptor instead. func (*AgentIngress) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{29} + return file_api_sam_proto_rawDescGZIP(), []int{33} } func (x *AgentIngress) GetType() ServiceType { @@ -2288,7 +2552,7 @@ type AgentBundle struct { func (x *AgentBundle) Reset() { *x = AgentBundle{} - mi := &file_api_sam_proto_msgTypes[30] + mi := &file_api_sam_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2300,7 +2564,7 @@ func (x *AgentBundle) String() string { func (*AgentBundle) ProtoMessage() {} func (x *AgentBundle) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[30] + mi := &file_api_sam_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2313,7 +2577,7 @@ func (x *AgentBundle) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentBundle.ProtoReflect.Descriptor instead. func (*AgentBundle) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{30} + return file_api_sam_proto_rawDescGZIP(), []int{34} } func (x *AgentBundle) GetVersion() string { @@ -2369,7 +2633,7 @@ type AgentAttachRequest struct { func (x *AgentAttachRequest) Reset() { *x = AgentAttachRequest{} - mi := &file_api_sam_proto_msgTypes[31] + mi := &file_api_sam_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2381,7 +2645,7 @@ func (x *AgentAttachRequest) String() string { func (*AgentAttachRequest) ProtoMessage() {} func (x *AgentAttachRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[31] + mi := &file_api_sam_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2394,7 +2658,7 @@ func (x *AgentAttachRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentAttachRequest.ProtoReflect.Descriptor instead. func (*AgentAttachRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{31} + return file_api_sam_proto_rawDescGZIP(), []int{35} } func (x *AgentAttachRequest) GetBundle() *AgentBundle { @@ -2418,7 +2682,7 @@ type AgentAttachResponse struct { func (x *AgentAttachResponse) Reset() { *x = AgentAttachResponse{} - mi := &file_api_sam_proto_msgTypes[32] + mi := &file_api_sam_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2430,7 +2694,7 @@ func (x *AgentAttachResponse) String() string { func (*AgentAttachResponse) ProtoMessage() {} func (x *AgentAttachResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[32] + mi := &file_api_sam_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2443,7 +2707,7 @@ func (x *AgentAttachResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentAttachResponse.ProtoReflect.Descriptor instead. func (*AgentAttachResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{32} + return file_api_sam_proto_rawDescGZIP(), []int{36} } func (x *AgentAttachResponse) GetEgressSocket() string { @@ -2478,7 +2742,7 @@ type AgentDetachRequest struct { func (x *AgentDetachRequest) Reset() { *x = AgentDetachRequest{} - mi := &file_api_sam_proto_msgTypes[33] + mi := &file_api_sam_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2490,7 +2754,7 @@ func (x *AgentDetachRequest) String() string { func (*AgentDetachRequest) ProtoMessage() {} func (x *AgentDetachRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[33] + mi := &file_api_sam_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2503,7 +2767,7 @@ func (x *AgentDetachRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentDetachRequest.ProtoReflect.Descriptor instead. func (*AgentDetachRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{33} + return file_api_sam_proto_rawDescGZIP(), []int{37} } func (x *AgentDetachRequest) GetAgentId() string { @@ -2523,7 +2787,7 @@ type AgentDetachResponse struct { func (x *AgentDetachResponse) Reset() { *x = AgentDetachResponse{} - mi := &file_api_sam_proto_msgTypes[34] + mi := &file_api_sam_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2535,7 +2799,7 @@ func (x *AgentDetachResponse) String() string { func (*AgentDetachResponse) ProtoMessage() {} func (x *AgentDetachResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[34] + mi := &file_api_sam_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2548,7 +2812,7 @@ func (x *AgentDetachResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentDetachResponse.ProtoReflect.Descriptor instead. func (*AgentDetachResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{34} + return file_api_sam_proto_rawDescGZIP(), []int{38} } func (x *AgentDetachResponse) GetSuccess() bool { @@ -2578,7 +2842,7 @@ type AgentRefreshRequest struct { func (x *AgentRefreshRequest) Reset() { *x = AgentRefreshRequest{} - mi := &file_api_sam_proto_msgTypes[35] + mi := &file_api_sam_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2590,7 +2854,7 @@ func (x *AgentRefreshRequest) String() string { func (*AgentRefreshRequest) ProtoMessage() {} func (x *AgentRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[35] + mi := &file_api_sam_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2603,7 +2867,7 @@ func (x *AgentRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentRefreshRequest.ProtoReflect.Descriptor instead. func (*AgentRefreshRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{35} + return file_api_sam_proto_rawDescGZIP(), []int{39} } func (x *AgentRefreshRequest) GetAgentId() string { @@ -2631,7 +2895,7 @@ type AgentRefreshResponse struct { func (x *AgentRefreshResponse) Reset() { *x = AgentRefreshResponse{} - mi := &file_api_sam_proto_msgTypes[36] + mi := &file_api_sam_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2643,7 +2907,7 @@ func (x *AgentRefreshResponse) String() string { func (*AgentRefreshResponse) ProtoMessage() {} func (x *AgentRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[36] + mi := &file_api_sam_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2656,7 +2920,7 @@ func (x *AgentRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentRefreshResponse.ProtoReflect.Descriptor instead. func (*AgentRefreshResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{36} + return file_api_sam_proto_rawDescGZIP(), []int{40} } func (x *AgentRefreshResponse) GetSuccess() bool { @@ -2691,7 +2955,7 @@ type AgentStatusRequest struct { func (x *AgentStatusRequest) Reset() { *x = AgentStatusRequest{} - mi := &file_api_sam_proto_msgTypes[37] + mi := &file_api_sam_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2703,7 +2967,7 @@ func (x *AgentStatusRequest) String() string { func (*AgentStatusRequest) ProtoMessage() {} func (x *AgentStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[37] + mi := &file_api_sam_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2716,7 +2980,7 @@ func (x *AgentStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentStatusRequest.ProtoReflect.Descriptor instead. func (*AgentStatusRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{37} + return file_api_sam_proto_rawDescGZIP(), []int{41} } func (x *AgentStatusRequest) GetAgentId() string { @@ -2738,7 +3002,7 @@ type AgentStatus struct { func (x *AgentStatus) Reset() { *x = AgentStatus{} - mi := &file_api_sam_proto_msgTypes[38] + mi := &file_api_sam_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2750,7 +3014,7 @@ func (x *AgentStatus) String() string { func (*AgentStatus) ProtoMessage() {} func (x *AgentStatus) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[38] + mi := &file_api_sam_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2763,7 +3027,7 @@ func (x *AgentStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentStatus.ProtoReflect.Descriptor instead. func (*AgentStatus) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{38} + return file_api_sam_proto_rawDescGZIP(), []int{42} } func (x *AgentStatus) GetAgentId() string { @@ -2804,7 +3068,7 @@ type AgentStatusResponse struct { func (x *AgentStatusResponse) Reset() { *x = AgentStatusResponse{} - mi := &file_api_sam_proto_msgTypes[39] + mi := &file_api_sam_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2816,7 +3080,7 @@ func (x *AgentStatusResponse) String() string { func (*AgentStatusResponse) ProtoMessage() {} func (x *AgentStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[39] + mi := &file_api_sam_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2829,7 +3093,7 @@ func (x *AgentStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentStatusResponse.ProtoReflect.Descriptor instead. func (*AgentStatusResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{39} + return file_api_sam_proto_rawDescGZIP(), []int{43} } func (x *AgentStatusResponse) GetAgents() []*AgentStatus { @@ -2860,7 +3124,7 @@ type IdentityEvidenceResponse struct { func (x *IdentityEvidenceResponse) Reset() { *x = IdentityEvidenceResponse{} - mi := &file_api_sam_proto_msgTypes[40] + mi := &file_api_sam_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2872,7 +3136,7 @@ func (x *IdentityEvidenceResponse) String() string { func (*IdentityEvidenceResponse) ProtoMessage() {} func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[40] + mi := &file_api_sam_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2885,7 +3149,7 @@ func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use IdentityEvidenceResponse.ProtoReflect.Descriptor instead. func (*IdentityEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{40} + return file_api_sam_proto_rawDescGZIP(), []int{44} } func (x *IdentityEvidenceResponse) GetPeerId() string { @@ -2946,7 +3210,7 @@ type PeerEvidenceResponse struct { func (x *PeerEvidenceResponse) Reset() { *x = PeerEvidenceResponse{} - mi := &file_api_sam_proto_msgTypes[41] + mi := &file_api_sam_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2958,7 +3222,7 @@ func (x *PeerEvidenceResponse) String() string { func (*PeerEvidenceResponse) ProtoMessage() {} func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[41] + mi := &file_api_sam_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2971,7 +3235,7 @@ func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerEvidenceResponse.ProtoReflect.Descriptor instead. func (*PeerEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{41} + return file_api_sam_proto_rawDescGZIP(), []int{45} } func (x *PeerEvidenceResponse) GetPeerId() string { @@ -3056,7 +3320,7 @@ type MemberCredential struct { func (x *MemberCredential) Reset() { *x = MemberCredential{} - mi := &file_api_sam_proto_msgTypes[42] + mi := &file_api_sam_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3068,7 +3332,7 @@ func (x *MemberCredential) String() string { func (*MemberCredential) ProtoMessage() {} func (x *MemberCredential) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[42] + mi := &file_api_sam_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3081,7 +3345,7 @@ func (x *MemberCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use MemberCredential.ProtoReflect.Descriptor instead. func (*MemberCredential) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{42} + return file_api_sam_proto_rawDescGZIP(), []int{46} } func (x *MemberCredential) GetControlPlaneUrl() string { @@ -3146,7 +3410,7 @@ type TrustedSigningKey struct { func (x *TrustedSigningKey) Reset() { *x = TrustedSigningKey{} - mi := &file_api_sam_proto_msgTypes[43] + mi := &file_api_sam_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3158,7 +3422,7 @@ func (x *TrustedSigningKey) String() string { func (*TrustedSigningKey) ProtoMessage() {} func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[43] + mi := &file_api_sam_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3171,7 +3435,7 @@ func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TrustedSigningKey.ProtoReflect.Descriptor instead. func (*TrustedSigningKey) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{43} + return file_api_sam_proto_rawDescGZIP(), []int{47} } func (x *TrustedSigningKey) GetPublicKey() []byte { @@ -3200,7 +3464,7 @@ type OIDCSession struct { func (x *OIDCSession) Reset() { *x = OIDCSession{} - mi := &file_api_sam_proto_msgTypes[44] + mi := &file_api_sam_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3212,7 +3476,7 @@ func (x *OIDCSession) String() string { func (*OIDCSession) ProtoMessage() {} func (x *OIDCSession) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[44] + mi := &file_api_sam_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3225,7 +3489,7 @@ func (x *OIDCSession) ProtoReflect() protoreflect.Message { // Deprecated: Use OIDCSession.ProtoReflect.Descriptor instead. func (*OIDCSession) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{44} + return file_api_sam_proto_rawDescGZIP(), []int{48} } func (x *OIDCSession) GetIssuer() string { @@ -3373,7 +3637,7 @@ const file_api_sam_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12;\n" + "\vexpire_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expireTime\"\xe9\x01\n" + + "expireTime\"\x90\x02\n" + "\n" + "PolicyRole\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12'\n" + @@ -3381,19 +3645,36 @@ const file_api_sam_proto_rawDesc = "" + "\x10allowed_services\x18\x03 \x03(\tR\x0fallowedServices\x12%\n" + "\x0ecustom_datalog\x18\x04 \x03(\tR\rcustomDatalog\x12%\n" + "\x0eallowed_agents\x18\x05 \x03(\tR\rallowedAgents\x12%\n" + - "\x0eallowed_labels\x18\x06 \x03(\tR\rallowedLabels\"=\n" + + "\x0eallowed_labels\x18\x06 \x03(\tR\rallowedLabels\x12%\n" + + "\x04http\x18\a \x03(\v2\x11.sam.v1.HTTPGrantR\x04http\"U\n" + + "\tHTTPGrant\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x18\n" + + "\amethods\x18\x02 \x03(\tR\amethods\x12\x14\n" + + "\x05paths\x18\x03 \x03(\tR\x05paths\"\x83\x01\n" + + "\x11EgressDestination\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "target_url\x18\x02 \x01(\tR\ttargetUrl\x12\x1e\n" + + "\n" + + "credential\x18\x03 \x01(\tR\n" + + "credential\x12\x1b\n" + + "\tserved_by\x18\x04 \x03(\tR\bservedBy\"=\n" + "\rPolicyBinding\x12\x12\n" + "\x04role\x18\x01 \x01(\tR\x04role\x12\x18\n" + - "\amembers\x18\x02 \x03(\tR\amembers\"k\n" + + "\amembers\x18\x02 \x03(\tR\amembers\"\x9e\x01\n" + "\fPolicyConfig\x12(\n" + "\x05roles\x18\x01 \x03(\v2\x12.sam.v1.PolicyRoleR\x05roles\x121\n" + - "\bbindings\x18\x02 \x03(\v2\x15.sam.v1.PolicyBindingR\bbindings\"\x18\n" + + "\bbindings\x18\x02 \x03(\v2\x15.sam.v1.PolicyBindingR\bbindings\x121\n" + + "\x06egress\x18\x03 \x03(\v2\x19.sam.v1.EgressDestinationR\x06egress\"\x18\n" + "\x16PolicyConfigGetRequest\"[\n" + "\x17PolicyConfigGetResponse\x12#\n" + "\rdatalog_rules\x18\x03 \x03(\tR\fdatalogRulesJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x05rolesR\bbindings\"L\n" + "\x1aPolicyConfigUpdateResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\"\x88\x01\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x1a\n" + + "\x18EgressAssignmentsRequest\"N\n" + + "\x19EgressAssignmentsResponse\x121\n" + + "\x06egress\x18\x01 \x03(\v2\x19.sam.v1.EgressDestinationR\x06egress\"\x88\x01\n" + "\fKeysResponse\x12\x1f\n" + "\vpublic_keys\x18\x01 \x03(\fR\n" + "publicKeys\x127\n" + @@ -3513,12 +3794,13 @@ const file_api_sam_proto_rawDesc = "" + "\x1dENROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n" + "\x19ENROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n" + "\x1aENROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n" + - "\x1aENROLLMENT_STATUS_REJECTED\x10\x03*s\n" + + "\x1aENROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n" + "\vServiceType\x12\x1c\n" + "\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n" + "\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n" + "\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n" + - "\x10SERVICE_TYPE_A2A\x10\x03B\x1bZ\x19github.com/google/sam/apib\x06proto3" + "\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n" + + "\x13SERVICE_TYPE_EGRESS\x10\x04B\x1bZ\x19github.com/google/sam/apib\x06proto3" var ( file_api_sam_proto_rawDescOnce sync.Once @@ -3533,7 +3815,7 @@ func file_api_sam_proto_rawDescGZIP() []byte { } var file_api_sam_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 50) +var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 54) var file_api_sam_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: sam.v1.EnrollmentStatus (ServiceType)(0), // 1: sam.v1.ServiceType @@ -3554,86 +3836,93 @@ var file_api_sam_proto_goTypes = []any{ (*RouterLeaseRequest)(nil), // 16: sam.v1.RouterLeaseRequest (*RouterLeaseResponse)(nil), // 17: sam.v1.RouterLeaseResponse (*PolicyRole)(nil), // 18: sam.v1.PolicyRole - (*PolicyBinding)(nil), // 19: sam.v1.PolicyBinding - (*PolicyConfig)(nil), // 20: sam.v1.PolicyConfig - (*PolicyConfigGetRequest)(nil), // 21: sam.v1.PolicyConfigGetRequest - (*PolicyConfigGetResponse)(nil), // 22: sam.v1.PolicyConfigGetResponse - (*PolicyConfigUpdateResponse)(nil), // 23: sam.v1.PolicyConfigUpdateResponse - (*KeysResponse)(nil), // 24: sam.v1.KeysResponse - (*TokenRefreshRequest)(nil), // 25: sam.v1.TokenRefreshRequest - (*TokenRefreshResponse)(nil), // 26: sam.v1.TokenRefreshResponse - (*NodeCatalogReport)(nil), // 27: sam.v1.NodeCatalogReport - (*TokenRevokeRequest)(nil), // 28: sam.v1.TokenRevokeRequest - (*TokenRevokeResponse)(nil), // 29: sam.v1.TokenRevokeResponse - (*AgentSecret)(nil), // 30: sam.v1.AgentSecret - (*AgentEgress)(nil), // 31: sam.v1.AgentEgress - (*AgentIngress)(nil), // 32: sam.v1.AgentIngress - (*AgentBundle)(nil), // 33: sam.v1.AgentBundle - (*AgentAttachRequest)(nil), // 34: sam.v1.AgentAttachRequest - (*AgentAttachResponse)(nil), // 35: sam.v1.AgentAttachResponse - (*AgentDetachRequest)(nil), // 36: sam.v1.AgentDetachRequest - (*AgentDetachResponse)(nil), // 37: sam.v1.AgentDetachResponse - (*AgentRefreshRequest)(nil), // 38: sam.v1.AgentRefreshRequest - (*AgentRefreshResponse)(nil), // 39: sam.v1.AgentRefreshResponse - (*AgentStatusRequest)(nil), // 40: sam.v1.AgentStatusRequest - (*AgentStatus)(nil), // 41: sam.v1.AgentStatus - (*AgentStatusResponse)(nil), // 42: sam.v1.AgentStatusResponse - (*IdentityEvidenceResponse)(nil), // 43: sam.v1.IdentityEvidenceResponse - (*PeerEvidenceResponse)(nil), // 44: sam.v1.PeerEvidenceResponse - (*MemberCredential)(nil), // 45: sam.v1.MemberCredential - (*TrustedSigningKey)(nil), // 46: sam.v1.TrustedSigningKey - (*OIDCSession)(nil), // 47: sam.v1.OIDCSession - nil, // 48: sam.v1.EnrollRequest.LabelsEntry - nil, // 49: sam.v1.BootstrapEnrollRequest.LabelsEntry - nil, // 50: sam.v1.CommandBackend.EnvEntry - nil, // 51: sam.v1.ServiceAnnounce.LabelsEntry - nil, // 52: sam.v1.PeerEvidenceResponse.LabelsEntry - (*timestamppb.Timestamp)(nil), // 53: google.protobuf.Timestamp + (*HTTPGrant)(nil), // 19: sam.v1.HTTPGrant + (*EgressDestination)(nil), // 20: sam.v1.EgressDestination + (*PolicyBinding)(nil), // 21: sam.v1.PolicyBinding + (*PolicyConfig)(nil), // 22: sam.v1.PolicyConfig + (*PolicyConfigGetRequest)(nil), // 23: sam.v1.PolicyConfigGetRequest + (*PolicyConfigGetResponse)(nil), // 24: sam.v1.PolicyConfigGetResponse + (*PolicyConfigUpdateResponse)(nil), // 25: sam.v1.PolicyConfigUpdateResponse + (*EgressAssignmentsRequest)(nil), // 26: sam.v1.EgressAssignmentsRequest + (*EgressAssignmentsResponse)(nil), // 27: sam.v1.EgressAssignmentsResponse + (*KeysResponse)(nil), // 28: sam.v1.KeysResponse + (*TokenRefreshRequest)(nil), // 29: sam.v1.TokenRefreshRequest + (*TokenRefreshResponse)(nil), // 30: sam.v1.TokenRefreshResponse + (*NodeCatalogReport)(nil), // 31: sam.v1.NodeCatalogReport + (*TokenRevokeRequest)(nil), // 32: sam.v1.TokenRevokeRequest + (*TokenRevokeResponse)(nil), // 33: sam.v1.TokenRevokeResponse + (*AgentSecret)(nil), // 34: sam.v1.AgentSecret + (*AgentEgress)(nil), // 35: sam.v1.AgentEgress + (*AgentIngress)(nil), // 36: sam.v1.AgentIngress + (*AgentBundle)(nil), // 37: sam.v1.AgentBundle + (*AgentAttachRequest)(nil), // 38: sam.v1.AgentAttachRequest + (*AgentAttachResponse)(nil), // 39: sam.v1.AgentAttachResponse + (*AgentDetachRequest)(nil), // 40: sam.v1.AgentDetachRequest + (*AgentDetachResponse)(nil), // 41: sam.v1.AgentDetachResponse + (*AgentRefreshRequest)(nil), // 42: sam.v1.AgentRefreshRequest + (*AgentRefreshResponse)(nil), // 43: sam.v1.AgentRefreshResponse + (*AgentStatusRequest)(nil), // 44: sam.v1.AgentStatusRequest + (*AgentStatus)(nil), // 45: sam.v1.AgentStatus + (*AgentStatusResponse)(nil), // 46: sam.v1.AgentStatusResponse + (*IdentityEvidenceResponse)(nil), // 47: sam.v1.IdentityEvidenceResponse + (*PeerEvidenceResponse)(nil), // 48: sam.v1.PeerEvidenceResponse + (*MemberCredential)(nil), // 49: sam.v1.MemberCredential + (*TrustedSigningKey)(nil), // 50: sam.v1.TrustedSigningKey + (*OIDCSession)(nil), // 51: sam.v1.OIDCSession + nil, // 52: sam.v1.EnrollRequest.LabelsEntry + nil, // 53: sam.v1.BootstrapEnrollRequest.LabelsEntry + nil, // 54: sam.v1.CommandBackend.EnvEntry + nil, // 55: sam.v1.ServiceAnnounce.LabelsEntry + nil, // 56: sam.v1.PeerEvidenceResponse.LabelsEntry + (*timestamppb.Timestamp)(nil), // 57: google.protobuf.Timestamp } var file_api_sam_proto_depIdxs = []int32{ 2, // 0: sam.v1.MeshEvent.type:type_name -> sam.v1.MeshEvent.Type - 53, // 1: sam.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp - 48, // 2: sam.v1.EnrollRequest.labels:type_name -> sam.v1.EnrollRequest.LabelsEntry - 53, // 3: sam.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp - 49, // 4: sam.v1.BootstrapEnrollRequest.labels:type_name -> sam.v1.BootstrapEnrollRequest.LabelsEntry + 57, // 1: sam.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp + 52, // 2: sam.v1.EnrollRequest.labels:type_name -> sam.v1.EnrollRequest.LabelsEntry + 57, // 3: sam.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 53, // 4: sam.v1.BootstrapEnrollRequest.labels:type_name -> sam.v1.BootstrapEnrollRequest.LabelsEntry 0, // 5: sam.v1.BootstrapEnrollResponse.status:type_name -> sam.v1.EnrollmentStatus - 53, // 6: sam.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 57, // 6: sam.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp 1, // 7: sam.v1.ServiceInfo.type:type_name -> sam.v1.ServiceType - 50, // 8: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry + 54, // 8: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry 10, // 9: sam.v1.RegisterServiceRequest.service:type_name -> sam.v1.ServiceInfo 11, // 10: sam.v1.RegisterServiceRequest.command:type_name -> sam.v1.CommandBackend 1, // 11: sam.v1.ServiceAnnounce.type:type_name -> sam.v1.ServiceType - 51, // 12: sam.v1.ServiceAnnounce.labels:type_name -> sam.v1.ServiceAnnounce.LabelsEntry - 53, // 13: sam.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp - 53, // 14: sam.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp - 18, // 15: sam.v1.PolicyConfig.roles:type_name -> sam.v1.PolicyRole - 19, // 16: sam.v1.PolicyConfig.bindings:type_name -> sam.v1.PolicyBinding - 53, // 17: sam.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp - 53, // 18: sam.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp - 10, // 19: sam.v1.NodeCatalogReport.services:type_name -> sam.v1.ServiceInfo - 30, // 20: sam.v1.AgentEgress.secrets:type_name -> sam.v1.AgentSecret - 1, // 21: sam.v1.AgentIngress.type:type_name -> sam.v1.ServiceType - 31, // 22: sam.v1.AgentBundle.egress:type_name -> sam.v1.AgentEgress - 32, // 23: sam.v1.AgentBundle.ingress:type_name -> sam.v1.AgentIngress - 33, // 24: sam.v1.AgentAttachRequest.bundle:type_name -> sam.v1.AgentBundle - 53, // 25: sam.v1.AgentRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp - 32, // 26: sam.v1.AgentStatus.ingress:type_name -> sam.v1.AgentIngress - 53, // 27: sam.v1.AgentStatus.credential_expire_time:type_name -> google.protobuf.Timestamp - 41, // 28: sam.v1.AgentStatusResponse.agents:type_name -> sam.v1.AgentStatus - 53, // 29: sam.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp - 53, // 30: sam.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 52, // 31: sam.v1.PeerEvidenceResponse.labels:type_name -> sam.v1.PeerEvidenceResponse.LabelsEntry - 53, // 32: sam.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp - 53, // 33: sam.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 53, // 34: sam.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp - 46, // 35: sam.v1.MemberCredential.trusted_keys:type_name -> sam.v1.TrustedSigningKey - 47, // 36: sam.v1.MemberCredential.oidc_session:type_name -> sam.v1.OIDCSession - 53, // 37: sam.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp - 38, // [38:38] is the sub-list for method output_type - 38, // [38:38] is the sub-list for method input_type - 38, // [38:38] is the sub-list for extension type_name - 38, // [38:38] is the sub-list for extension extendee - 0, // [0:38] is the sub-list for field type_name + 55, // 12: sam.v1.ServiceAnnounce.labels:type_name -> sam.v1.ServiceAnnounce.LabelsEntry + 57, // 13: sam.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp + 57, // 14: sam.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp + 19, // 15: sam.v1.PolicyRole.http:type_name -> sam.v1.HTTPGrant + 18, // 16: sam.v1.PolicyConfig.roles:type_name -> sam.v1.PolicyRole + 21, // 17: sam.v1.PolicyConfig.bindings:type_name -> sam.v1.PolicyBinding + 20, // 18: sam.v1.PolicyConfig.egress:type_name -> sam.v1.EgressDestination + 20, // 19: sam.v1.EgressAssignmentsResponse.egress:type_name -> sam.v1.EgressDestination + 57, // 20: sam.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp + 57, // 21: sam.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp + 10, // 22: sam.v1.NodeCatalogReport.services:type_name -> sam.v1.ServiceInfo + 34, // 23: sam.v1.AgentEgress.secrets:type_name -> sam.v1.AgentSecret + 1, // 24: sam.v1.AgentIngress.type:type_name -> sam.v1.ServiceType + 35, // 25: sam.v1.AgentBundle.egress:type_name -> sam.v1.AgentEgress + 36, // 26: sam.v1.AgentBundle.ingress:type_name -> sam.v1.AgentIngress + 37, // 27: sam.v1.AgentAttachRequest.bundle:type_name -> sam.v1.AgentBundle + 57, // 28: sam.v1.AgentRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp + 36, // 29: sam.v1.AgentStatus.ingress:type_name -> sam.v1.AgentIngress + 57, // 30: sam.v1.AgentStatus.credential_expire_time:type_name -> google.protobuf.Timestamp + 45, // 31: sam.v1.AgentStatusResponse.agents:type_name -> sam.v1.AgentStatus + 57, // 32: sam.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp + 57, // 33: sam.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 56, // 34: sam.v1.PeerEvidenceResponse.labels:type_name -> sam.v1.PeerEvidenceResponse.LabelsEntry + 57, // 35: sam.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp + 57, // 36: sam.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 57, // 37: sam.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp + 50, // 38: sam.v1.MemberCredential.trusted_keys:type_name -> sam.v1.TrustedSigningKey + 51, // 39: sam.v1.MemberCredential.oidc_session:type_name -> sam.v1.OIDCSession + 57, // 40: sam.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp + 41, // [41:41] is the sub-list for method output_type + 41, // [41:41] is the sub-list for method input_type + 41, // [41:41] is the sub-list for extension type_name + 41, // [41:41] is the sub-list for extension extendee + 0, // [0:41] is the sub-list for field type_name } func init() { file_api_sam_proto_init() } @@ -3651,7 +3940,7 @@ func file_api_sam_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_sam_proto_rawDesc), len(file_api_sam_proto_rawDesc)), NumEnums: 3, - NumMessages: 50, + NumMessages: 54, NumExtensions: 0, NumServices: 0, }, diff --git a/api/sam.proto b/api/sam.proto index 04f9eed7..1e6fe59c 100644 --- a/api/sam.proto +++ b/api/sam.proto @@ -133,6 +133,12 @@ enum ServiceType { SERVICE_TYPE_MCP = 1; SERVICE_TYPE_INFERENCE = 2; SERVICE_TYPE_A2A = 3; + // A destination outside the mesh, reached through a node that enforces + // policy on it. The service name is the destination hostname, so a grant + // reads egress://api.github.com and the request fact + // service("egress", "api.github.com"). Egress names have no .sam.alt form: + // a sandboxed agent connects to the destination name itself. + SERVICE_TYPE_EGRESS = 4; } message ServiceInfo { @@ -230,6 +236,48 @@ message PolicyRole { // or "key=value". A node declares its own labels, so this is what turns a // declaration into something the control plane is willing to sign. repeated string allowed_labels = 6; + // HTTP narrowing of allowed_services entries; see HTTPGrant. + repeated HTTPGrant http = 7; +} + +// HTTPGrant narrows one allowed_services entry to HTTP methods and paths. +// The control plane compiles it into granted_method and granted_path_* facts +// in the holder's credential and withholds the plain service grant for that +// entry. The baseline rules derive the service grant only for a request +// whose method($m) and path($p) facts match, so a request that carries no +// HTTP method (a tunnel, a non-HTTP stream) does not match a narrowed entry. +message HTTPGrant { + // One of the role's allowed_services entries, written identically. + string service = 1; + // Methods the holder may use, e.g. "GET", "HEAD". Empty means any method. + repeated string methods = 2; + // Paths the holder may request, as the backend sees them: "/user" matches + // that path only, "/v2/public/*" matches every path under the prefix. + // Empty means any path. At least one of methods and paths must be set. + repeated string paths = 3; +} + +// EgressDestination is a destination outside the mesh that selected nodes +// serve as egress://. It is part of the policy document the admin +// writes; a node receives the destinations that select it at GET /egress +// and serves them without configuration of its own. +message EgressDestination { + // The destination hostname, lowercase, without a port or a path. It is the + // service name in grants (egress://) and the DHT key. + string name = 1; + // Where the serving node forwards requests. Optional; https:// when + // empty. Must not carry a credential. + string target_url = 2; + // Name of the credential the serving node presents upstream, resolved by + // the node from its secrets directory. Never a value: secret material does + // not travel through this API. + string credential = 3; + // Role names or key=value labels selecting the nodes that serve this + // destination. A node matches when any entry names one of its roles or + // labels. The control plane also grants the destination to the selected + // nodes, so the serving node authorizes local requests with its own + // credential; other callers need the grant on their own role. + repeated string served_by = 4; } message PolicyBinding { @@ -244,6 +292,7 @@ message PolicyBinding { message PolicyConfig { repeated PolicyRole roles = 1; repeated PolicyBinding bindings = 2; + repeated EgressDestination egress = 3; } message PolicyConfigGetRequest {} @@ -265,6 +314,16 @@ message PolicyConfigUpdateResponse { string error = 2; } +message EgressAssignmentsRequest {} + +// EgressAssignmentsResponse answers GET /egress for a mesh member holding a +// biscuit: the destinations whose served_by selects that node. It is a +// separate endpoint from GET /policies so that a node predating it keeps +// syncing rules unchanged. +message EgressAssignmentsResponse { + repeated EgressDestination egress = 1; +} + message KeysResponse { repeated bytes public_keys = 1; // When the set was signed; receivers reject responses outside a short diff --git a/hack/gen-sdk-datalog/main.go b/hack/gen-sdk-datalog/main.go index 39172347..cd34680c 100644 --- a/hack/gen-sdk-datalog/main.go +++ b/hack/gen-sdk-datalog/main.go @@ -34,6 +34,8 @@ type artifact struct { FactService string `json:"fact_service"` FactConnectionPeer string `json:"fact_connection_peer_id"` FactAgent string `json:"fact_agent"` + FactMethod string `json:"fact_method"` + FactPath string `json:"fact_path"` FactTime string `json:"fact_time"` FactRole string `json:"fact_role"` FactTargetFact string `json:"fact_target_fact"` @@ -54,6 +56,8 @@ func main() { FactService: api.FactService, FactConnectionPeer: api.FactConnectionPeerID, FactAgent: api.FactAgent, + FactMethod: api.FactMethod, + FactPath: api.FactPath, FactTime: api.FactTime, FactRole: api.FactRole, FactTargetFact: api.FactTargetFact, diff --git a/internal/identity/biscuit.go b/internal/identity/biscuit.go index f4556274..433f4226 100644 --- a/internal/identity/biscuit.go +++ b/internal/identity/biscuit.go @@ -242,6 +242,9 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri // is accepted, which is what stops an unconfigured mesh from letting any // peer name any agent. var allAgents []string + // Narrowed grants (PolicyRole.http) are minted per entry: they are keyed + // by the entry they narrow, so there is nothing to merge across roles. + var allHTTP []*api.HTTPGrant for _, role := range roles { if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ Name: api.FactRole, @@ -265,7 +268,9 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri } if pr, ok := rolesMap[role]; ok { - allServices = append(allServices, pr.AllowedServices...) + plainServices, narrowed := api.SplitHTTPGrants(pr) + allServices = append(allServices, plainServices...) + allHTTP = append(allHTTP, narrowed...) allTargets = append(allTargets, pr.AllowedTargets...) allAgents = append(allAgents, pr.AllowedAgents...) @@ -297,6 +302,13 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri errs = append(errs, fmt.Errorf("failed to add service fact: %w", err)) } } + for _, g := range allHTTP { + for _, fact := range api.BuildHTTPGrantFacts(g) { + if err := addFact(fact); err != nil { + errs = append(errs, fmt.Errorf("failed to add http grant fact: %w", err)) + } + } + } for _, fact := range api.BuildTargetDatalogFacts(allTargets) { if err := addFact(fact); err != nil { errs = append(errs, fmt.Errorf("failed to add target fact: %w", err)) diff --git a/internal/identity/http_grants_test.go b/internal/identity/http_grants_test.go new file mode 100644 index 00000000..08b4d802 --- /dev/null +++ b/internal/identity/http_grants_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + "time" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/google/sam/api" +) + +// TestMintNarrowedGrant pins what a role with PolicyRole.http mints: the +// narrowed entry appears as its http_granted_service_* fact with its method +// and path facts, and not as a plain grant; the other entries are unchanged. +func TestMintNarrowedGrant(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + peerID := newTestPeer(t) + + role := &api.PolicyRole{ + Name: "contractor", + AllowedServices: []string{"mcp://tools", "egress://mam.internal.example.com"}, + Http: []*api.HTTPGrant{{ + Service: "egress://mam.internal.example.com", + Methods: []string{"GET"}, + Paths: []string{"/v2/public/*"}, + }}, + } + token, err := MintBootstrapBiscuitToken(priv, peerID, "contractor", time.Now().Add(time.Hour), []*api.PolicyRole{role}, nil) + if err != nil { + t.Fatalf("mint: %v", err) + } + b, err := biscuit.Unmarshal(token) + if err != nil { + t.Fatal(err) + } + code := b.String() + + // Biscuit.String prints Set terms as symbol references, so the set facts + // are matched on their name and key only. + for _, want := range []string{ + `granted_service_set("mcp", [`, + `http_granted_service_exact("egress", "mam.internal.example.com")`, + `granted_method("egress", "mam.internal.example.com", [`, + `granted_path_prefix("egress", "mam.internal.example.com", "/v2/public/")`, + } { + if !strings.Contains(code, want) { + t.Errorf("token lacks %s; authority block:\n%s", want, code) + } + } + // The facts line lists them space-separated; a plain egress grant would + // start a token there, http_granted_service_exact would not. + for _, tok := range strings.Fields(code) { + if strings.HasPrefix(tok, `granted_service_exact("egress"`) || strings.HasPrefix(tok, `granted_service_set("egress"`) { + t.Errorf("narrowed entry minted as a plain grant: %s", tok) + } + } +} diff --git a/sdk/js/src/gen/datalog.ts b/sdk/js/src/gen/datalog.ts index 547b732b..a2ad2b7a 100644 --- a/sdk/js/src/gen/datalog.ts +++ b/sdk/js/src/gen/datalog.ts @@ -19,6 +19,18 @@ export const BASELINE_DATALOG = { "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_all($fact)", "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_all_facts(true)" ], + "http_rules": [ + "http_method_ok($t, $k) <- method($m), granted_method($t, $k, $set), $set.contains($m)", + "http_method_ok($t, $k) <- granted_method_any($t, $k)", + "http_path_ok($t, $k) <- path($p), granted_path_exact($t, $k, $set), $set.contains($p)", + "http_path_ok($t, $k) <- path($p), granted_path_prefix($t, $k, $prefix), $p.starts_with($prefix)", + "http_path_ok($t, $k) <- granted_path_any($t, $k)", + "granted_service_exact($t, $n) <- service($t, $n), http_granted_service_exact($t, $n), http_method_ok($t, $n), http_path_ok($t, $n)", + "granted_service_suffix($t, $s) <- service($t, $n), http_granted_service_suffix($t, $s), $n.ends_with($s), http_method_ok($t, $s), http_path_ok($t, $s)", + "granted_service_prefix($t, $p) <- service($t, $n), http_granted_service_prefix($t, $p), $n.starts_with($p), http_method_ok($t, $p), http_path_ok($t, $p)", + "granted_service_all($t) <- service($t, $n), http_granted_service_all($t), http_method_ok($t, \"*\"), http_path_ok($t, \"*\")", + "granted_service_all_types(true) <- service($t, $n), http_granted_service_all_types(true), http_method_ok(\"*\", \"*\"), http_path_ok(\"*\", \"*\")" + ], "agent_rules": [ "agent_authorized(true) <- agent($a), granted_agent_exact($a)", "agent_authorized(true) <- agent($a), granted_agent_set($set), $set.contains($a)", @@ -41,6 +53,8 @@ export const BASELINE_DATALOG = { "fact_service": "service", "fact_connection_peer_id": "connection_peer_id", "fact_agent": "agent", + "fact_method": "method", + "fact_path": "path", "fact_time": "time", "fact_role": "role", "fact_target_fact": "target_fact", diff --git a/sdk/js/src/gen/sam_pb.ts b/sdk/js/src/gen/sam_pb.ts index 94d899a9..4ee3d093 100644 --- a/sdk/js/src/gen/sam_pb.ts +++ b/sdk/js/src/gen/sam_pb.ts @@ -26,7 +26,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file sam.proto. */ export const file_sam: GenFile = /*@__PURE__*/ - fileDesc("CglzYW0ucHJvdG8SBnNhbS52MSJDCglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCRINCgVhZ2VudBgDIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMItYBCglNZXNoRXZlbnQSJAoEdHlwZRgBIAEoDjIWLnNhbS52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiLzAQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjEKBmxhYmVscxgFIAMoCzIhLnNhbS52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKRAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjoKBmxhYmVscxgFIAMoCzIqLnNhbS52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASL9AQoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USKAoGc3RhdHVzGAEgASgOMhguc2FtLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUwoLU2VydmljZUluZm8SIQoEdHlwZRgBIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJInsKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSLAoDZW52GAIgAygLMh8uc2FtLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiigEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSJAoHc2VydmljZRgBIAEoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASKQoHY29tbWFuZBgDIAEoCzIWLnNhbS52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSKyAgoPU2VydmljZUFubm91bmNlEg8KB3BlZXJfaWQYASABKAkSIQoEdHlwZRgCIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIUCgxzZXJ2aWNlX25hbWUYAyABKAkSDAoEa2V5cxgEIAMoCRIzCgZsYWJlbHMYBSADKAsyIy5zYW0udjEuU2VydmljZUFubm91bmNlLkxhYmVsc0VudHJ5EhcKD2FjdGl2ZV9yZXF1ZXN0cxgGIAEoDRIXCg9sYXRlbmN5X2V3bWFfbXMYByABKAESMQoNYW5ub3VuY2VfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKHAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCSKsAQoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKVAQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfYWdlbnRzGAUgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAYgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIloKDFBvbGljeUNvbmZpZxIhCgVyb2xlcxgBIAMoCzISLnNhbS52MS5Qb2xpY3lSb2xlEicKCGJpbmRpbmdzGAIgAygLMhUuc2FtLnYxLlBvbGljeUJpbmRpbmciGAoWUG9saWN5Q29uZmlnR2V0UmVxdWVzdCJNChdQb2xpY3lDb25maWdHZXRSZXNwb25zZRIVCg1kYXRhbG9nX3J1bGVzGAMgAygJSgQIARACSgQIAhADUgVyb2xlc1IIYmluZGluZ3MiPAoaUG9saWN5Q29uZmlnVXBkYXRlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSJmCgxLZXlzUmVzcG9uc2USEwoLcHVibGljX2tleXMYASADKAwSLQoJc2lnbl90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpzaWduYXR1cmVzGAMgAygMIl4KE1Rva2VuUmVmcmVzaFJlcXVlc3QSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgBIAEoDBIZChFjaGFsbGVuZ2VfdW5peF9tcxgCIAEoAxIPCgdwZWVyX2lkGAMgASgJInUKFFRva2VuUmVmcmVzaFJlc3BvbnNlEhUKDWJpc2N1aXRfdG9rZW4YASABKAwSLwoLZXhwaXJlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhUKDWVycm9yX21lc3NhZ2UYAyABKAkiOgoRTm9kZUNhdGFsb2dSZXBvcnQSJQoIc2VydmljZXMYASADKAsyEy5zYW0udjEuU2VydmljZUluZm8iJQoSVG9rZW5SZXZva2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkiNQoTVG9rZW5SZXZva2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIlIKC0FnZW50U2VjcmV0EgwKBGhvc3QYASABKAkSDAoEa2luZBgCIAEoCRITCgtoZWFkZXJfbmFtZRgDIAEoCRISCgp2YWx1ZV9wYXRoGAQgASgJIkIKC0FnZW50RWdyZXNzEg0KBWFsbG93GAEgAygJEiQKB3NlY3JldHMYAiADKAsyEy5zYW0udjEuQWdlbnRTZWNyZXQiYgoMQWdlbnRJbmdyZXNzEiEKBHR5cGUYASABKA4yEy5zYW0udjEuU2VydmljZVR5cGUSDAoEbmFtZRgCIAEoCRIMCgRwb3J0GAMgASgNEhMKC2Rlc2NyaXB0aW9uGAQgASgJIqoBCgtBZ2VudEJ1bmRsZRIPCgd2ZXJzaW9uGAEgASgJEhAKCGFnZW50X2lkGAIgASgJEhMKC2V4dGVybmFsX2lkGAMgASgJEhcKD2NyZWRlbnRpYWxfcGF0aBgEIAEoCRIjCgZlZ3Jlc3MYBSABKAsyEy5zYW0udjEuQWdlbnRFZ3Jlc3MSJQoHaW5ncmVzcxgGIAMoCzIULnNhbS52MS5BZ2VudEluZ3Jlc3MiOQoSQWdlbnRBdHRhY2hSZXF1ZXN0EiMKBmJ1bmRsZRgBIAEoCzITLnNhbS52MS5BZ2VudEJ1bmRsZSJTChNBZ2VudEF0dGFjaFJlc3BvbnNlEhUKDWVncmVzc19zb2NrZXQYASABKAkSFgoOaW5ncmVzc19zb2NrZXQYAiABKAkSDQoFZXJyb3IYAyABKAkiJgoSQWdlbnREZXRhY2hSZXF1ZXN0EhAKCGFnZW50X2lkGAEgASgJIjUKE0FnZW50RGV0YWNoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSJAChNBZ2VudFJlZnJlc2hSZXF1ZXN0EhAKCGFnZW50X2lkGAEgASgJEhcKD2NyZWRlbnRpYWxfcGF0aBgCIAEoCSJnChRBZ2VudFJlZnJlc2hSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCImChJBZ2VudFN0YXR1c1JlcXVlc3QSEAoIYWdlbnRfaWQYASABKAkilAEKC0FnZW50U3RhdHVzEhAKCGFnZW50X2lkGAEgASgJEhAKCGF0dGFjaGVkGAIgASgIEiUKB2luZ3Jlc3MYAyADKAsyFC5zYW0udjEuQWdlbnRJbmdyZXNzEjoKFmNyZWRlbnRpYWxfZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkkKE0FnZW50U3RhdHVzUmVzcG9uc2USIwoGYWdlbnRzGAEgAygLMhMuc2FtLnYxLkFnZW50U3RhdHVzEg0KBWVycm9yGAIgASgJIuQBChhJZGVudGl0eUV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEjcKE2Jpc2N1aXRfZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAQgASgJEiIKGnRydXN0ZWRfY29udHJvbF9wbGFuZV9rZXlzGAUgAygMEi4KCmNoZWNrX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIsACChRQZWVyRXZpZGVuY2VSZXNwb25zZRIPCgdwZWVyX2lkGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSFQoNdmVyaWZ5aW5nX2tleRgDIAEoDBINCgVyb2xlcxgEIAMoCRI4CgZsYWJlbHMYBSADKAsyKC5zYW0udjEuUGVlckV2aWRlbmNlUmVzcG9uc2UuTGFiZWxzRW50cnkSLwoLZXhwaXJlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDnJldm9jYXRpb25faWRzGAcgAygJEi4KCmNoZWNrX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEigAIKEE1lbWJlckNyZWRlbnRpYWwSGQoRY29udHJvbF9wbGFuZV91cmwYASABKAkSDwoHYmlzY3VpdBgCIAEoDBIvCgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoMdHJ1c3RlZF9rZXlzGAQgAygLMhkuc2FtLnYxLlRydXN0ZWRTaWduaW5nS2V5EhkKEWlzc3VlZF91bmRlcl9rZXlzGAUgAygMEhgKEHJvdXRlcl9hZGRyZXNzZXMYBiADKAkSKQoMb2lkY19zZXNzaW9uGAcgASgLMhMuc2FtLnYxLk9JRENTZXNzaW9uIlkKEVRydXN0ZWRTaWduaW5nS2V5EhIKCnB1YmxpY19rZXkYASABKAwSMAoMcmVjZWl2ZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJZCgtPSURDU2Vzc2lvbhIOCgZpc3N1ZXIYASABKAkSEQoJY2xpZW50X2lkGAIgASgJEhAKCGF1ZGllbmNlGAMgASgJEhUKDXJlZnJlc2hfdG9rZW4YBCABKAkqlAEKEEVucm9sbG1lbnRTdGF0dXMSIQodRU5ST0xMTUVOVF9TVEFUVVNfVU5TUEVDSUZJRUQQABIdChlFTlJPTExNRU5UX1NUQVRVU19QRU5ESU5HEAESHgoaRU5ST0xMTUVOVF9TVEFUVVNfQVBQUk9WRUQQAhIeChpFTlJPTExNRU5UX1NUQVRVU19SRUpFQ1RFRBADKnMKC1NlcnZpY2VUeXBlEhwKGFNFUlZJQ0VfVFlQRV9VTlNQRUNJRklFRBAAEhQKEFNFUlZJQ0VfVFlQRV9NQ1AQARIaChZTRVJWSUNFX1RZUEVfSU5GRVJFTkNFEAISFAoQU0VSVklDRV9UWVBFX0EyQRADQhtaGWdpdGh1Yi5jb20vZ29vZ2xlL3NhbS9hcGliBnByb3RvMw", [file_google_protobuf_timestamp]); + fileDesc("CglzYW0ucHJvdG8SBnNhbS52MSJDCglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCRINCgVhZ2VudBgDIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMItYBCglNZXNoRXZlbnQSJAoEdHlwZRgBIAEoDjIWLnNhbS52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiLzAQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjEKBmxhYmVscxgFIAMoCzIhLnNhbS52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKRAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjoKBmxhYmVscxgFIAMoCzIqLnNhbS52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASL9AQoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USKAoGc3RhdHVzGAEgASgOMhguc2FtLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUwoLU2VydmljZUluZm8SIQoEdHlwZRgBIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJInsKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSLAoDZW52GAIgAygLMh8uc2FtLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiigEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSJAoHc2VydmljZRgBIAEoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASKQoHY29tbWFuZBgDIAEoCzIWLnNhbS52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSKyAgoPU2VydmljZUFubm91bmNlEg8KB3BlZXJfaWQYASABKAkSIQoEdHlwZRgCIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIUCgxzZXJ2aWNlX25hbWUYAyABKAkSDAoEa2V5cxgEIAMoCRIzCgZsYWJlbHMYBSADKAsyIy5zYW0udjEuU2VydmljZUFubm91bmNlLkxhYmVsc0VudHJ5EhcKD2FjdGl2ZV9yZXF1ZXN0cxgGIAEoDRIXCg9sYXRlbmN5X2V3bWFfbXMYByABKAESMQoNYW5ub3VuY2VfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKHAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCSKsAQoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK2AQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfYWdlbnRzGAUgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAYgAygJEh8KBGh0dHAYByADKAsyES5zYW0udjEuSFRUUEdyYW50IjwKCUhUVFBHcmFudBIPCgdzZXJ2aWNlGAEgASgJEg8KB21ldGhvZHMYAiADKAkSDQoFcGF0aHMYAyADKAkiXAoRRWdyZXNzRGVzdGluYXRpb24SDAoEbmFtZRgBIAEoCRISCgp0YXJnZXRfdXJsGAIgASgJEhIKCmNyZWRlbnRpYWwYAyABKAkSEQoJc2VydmVkX2J5GAQgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIoUBCgxQb2xpY3lDb25maWcSIQoFcm9sZXMYASADKAsyEi5zYW0udjEuUG9saWN5Um9sZRInCghiaW5kaW5ncxgCIAMoCzIVLnNhbS52MS5Qb2xpY3lCaW5kaW5nEikKBmVncmVzcxgDIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiIYChZQb2xpY3lDb25maWdHZXRSZXF1ZXN0Ik0KF1BvbGljeUNvbmZpZ0dldFJlc3BvbnNlEhUKDWRhdGFsb2dfcnVsZXMYAyADKAlKBAgBEAJKBAgCEANSBXJvbGVzUghiaW5kaW5ncyI8ChpQb2xpY3lDb25maWdVcGRhdGVSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIhoKGEVncmVzc0Fzc2lnbm1lbnRzUmVxdWVzdCJGChlFZ3Jlc3NBc3NpZ25tZW50c1Jlc3BvbnNlEikKBmVncmVzcxgBIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiJmCgxLZXlzUmVzcG9uc2USEwoLcHVibGljX2tleXMYASADKAwSLQoJc2lnbl90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpzaWduYXR1cmVzGAMgAygMIl4KE1Rva2VuUmVmcmVzaFJlcXVlc3QSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgBIAEoDBIZChFjaGFsbGVuZ2VfdW5peF9tcxgCIAEoAxIPCgdwZWVyX2lkGAMgASgJInUKFFRva2VuUmVmcmVzaFJlc3BvbnNlEhUKDWJpc2N1aXRfdG9rZW4YASABKAwSLwoLZXhwaXJlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhUKDWVycm9yX21lc3NhZ2UYAyABKAkiOgoRTm9kZUNhdGFsb2dSZXBvcnQSJQoIc2VydmljZXMYASADKAsyEy5zYW0udjEuU2VydmljZUluZm8iJQoSVG9rZW5SZXZva2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkiNQoTVG9rZW5SZXZva2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIlIKC0FnZW50U2VjcmV0EgwKBGhvc3QYASABKAkSDAoEa2luZBgCIAEoCRITCgtoZWFkZXJfbmFtZRgDIAEoCRISCgp2YWx1ZV9wYXRoGAQgASgJIkIKC0FnZW50RWdyZXNzEg0KBWFsbG93GAEgAygJEiQKB3NlY3JldHMYAiADKAsyEy5zYW0udjEuQWdlbnRTZWNyZXQiYgoMQWdlbnRJbmdyZXNzEiEKBHR5cGUYASABKA4yEy5zYW0udjEuU2VydmljZVR5cGUSDAoEbmFtZRgCIAEoCRIMCgRwb3J0GAMgASgNEhMKC2Rlc2NyaXB0aW9uGAQgASgJIqoBCgtBZ2VudEJ1bmRsZRIPCgd2ZXJzaW9uGAEgASgJEhAKCGFnZW50X2lkGAIgASgJEhMKC2V4dGVybmFsX2lkGAMgASgJEhcKD2NyZWRlbnRpYWxfcGF0aBgEIAEoCRIjCgZlZ3Jlc3MYBSABKAsyEy5zYW0udjEuQWdlbnRFZ3Jlc3MSJQoHaW5ncmVzcxgGIAMoCzIULnNhbS52MS5BZ2VudEluZ3Jlc3MiOQoSQWdlbnRBdHRhY2hSZXF1ZXN0EiMKBmJ1bmRsZRgBIAEoCzITLnNhbS52MS5BZ2VudEJ1bmRsZSJTChNBZ2VudEF0dGFjaFJlc3BvbnNlEhUKDWVncmVzc19zb2NrZXQYASABKAkSFgoOaW5ncmVzc19zb2NrZXQYAiABKAkSDQoFZXJyb3IYAyABKAkiJgoSQWdlbnREZXRhY2hSZXF1ZXN0EhAKCGFnZW50X2lkGAEgASgJIjUKE0FnZW50RGV0YWNoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSJAChNBZ2VudFJlZnJlc2hSZXF1ZXN0EhAKCGFnZW50X2lkGAEgASgJEhcKD2NyZWRlbnRpYWxfcGF0aBgCIAEoCSJnChRBZ2VudFJlZnJlc2hSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCImChJBZ2VudFN0YXR1c1JlcXVlc3QSEAoIYWdlbnRfaWQYASABKAkilAEKC0FnZW50U3RhdHVzEhAKCGFnZW50X2lkGAEgASgJEhAKCGF0dGFjaGVkGAIgASgIEiUKB2luZ3Jlc3MYAyADKAsyFC5zYW0udjEuQWdlbnRJbmdyZXNzEjoKFmNyZWRlbnRpYWxfZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkkKE0FnZW50U3RhdHVzUmVzcG9uc2USIwoGYWdlbnRzGAEgAygLMhMuc2FtLnYxLkFnZW50U3RhdHVzEg0KBWVycm9yGAIgASgJIuQBChhJZGVudGl0eUV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEjcKE2Jpc2N1aXRfZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAQgASgJEiIKGnRydXN0ZWRfY29udHJvbF9wbGFuZV9rZXlzGAUgAygMEi4KCmNoZWNrX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIsACChRQZWVyRXZpZGVuY2VSZXNwb25zZRIPCgdwZWVyX2lkGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSFQoNdmVyaWZ5aW5nX2tleRgDIAEoDBINCgVyb2xlcxgEIAMoCRI4CgZsYWJlbHMYBSADKAsyKC5zYW0udjEuUGVlckV2aWRlbmNlUmVzcG9uc2UuTGFiZWxzRW50cnkSLwoLZXhwaXJlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDnJldm9jYXRpb25faWRzGAcgAygJEi4KCmNoZWNrX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEigAIKEE1lbWJlckNyZWRlbnRpYWwSGQoRY29udHJvbF9wbGFuZV91cmwYASABKAkSDwoHYmlzY3VpdBgCIAEoDBIvCgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoMdHJ1c3RlZF9rZXlzGAQgAygLMhkuc2FtLnYxLlRydXN0ZWRTaWduaW5nS2V5EhkKEWlzc3VlZF91bmRlcl9rZXlzGAUgAygMEhgKEHJvdXRlcl9hZGRyZXNzZXMYBiADKAkSKQoMb2lkY19zZXNzaW9uGAcgASgLMhMuc2FtLnYxLk9JRENTZXNzaW9uIlkKEVRydXN0ZWRTaWduaW5nS2V5EhIKCnB1YmxpY19rZXkYASABKAwSMAoMcmVjZWl2ZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJZCgtPSURDU2Vzc2lvbhIOCgZpc3N1ZXIYASABKAkSEQoJY2xpZW50X2lkGAIgASgJEhAKCGF1ZGllbmNlGAMgASgJEhUKDXJlZnJlc2hfdG9rZW4YBCABKAkqlAEKEEVucm9sbG1lbnRTdGF0dXMSIQodRU5ST0xMTUVOVF9TVEFUVVNfVU5TUEVDSUZJRUQQABIdChlFTlJPTExNRU5UX1NUQVRVU19QRU5ESU5HEAESHgoaRU5ST0xMTUVOVF9TVEFUVVNfQVBQUk9WRUQQAhIeChpFTlJPTExNRU5UX1NUQVRVU19SRUpFQ1RFRBADKowBCgtTZXJ2aWNlVHlwZRIcChhTRVJWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABIUChBTRVJWSUNFX1RZUEVfTUNQEAESGgoWU0VSVklDRV9UWVBFX0lORkVSRU5DRRACEhQKEFNFUlZJQ0VfVFlQRV9BMkEQAxIXChNTRVJWSUNFX1RZUEVfRUdSRVNTEARCG1oZZ2l0aHViLmNvbS9nb29nbGUvc2FtL2FwaWIGcHJvdG8z", [file_google_protobuf_timestamp]); /** * @generated from message sam.v1.AuthFrame @@ -725,6 +725,13 @@ export type PolicyRole = Message<"sam.v1.PolicyRole"> & { * @generated from field: repeated string allowed_labels = 6; */ allowedLabels: string[]; + + /** + * HTTP narrowing of allowed_services entries; see HTTPGrant. + * + * @generated from field: repeated sam.v1.HTTPGrant http = 7; + */ + http: HTTPGrant[]; }; /** @@ -734,6 +741,101 @@ export type PolicyRole = Message<"sam.v1.PolicyRole"> & { export const PolicyRoleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_sam, 15); +/** + * HTTPGrant narrows one allowed_services entry to HTTP methods and paths. + * The control plane compiles it into granted_method and granted_path_* facts + * in the holder's credential and withholds the plain service grant for that + * entry. The baseline rules derive the service grant only for a request + * whose method($m) and path($p) facts match, so a request that carries no + * HTTP method (a tunnel, a non-HTTP stream) does not match a narrowed entry. + * + * @generated from message sam.v1.HTTPGrant + */ +export type HTTPGrant = Message<"sam.v1.HTTPGrant"> & { + /** + * One of the role's allowed_services entries, written identically. + * + * @generated from field: string service = 1; + */ + service: string; + + /** + * Methods the holder may use, e.g. "GET", "HEAD". Empty means any method. + * + * @generated from field: repeated string methods = 2; + */ + methods: string[]; + + /** + * Paths the holder may request, as the backend sees them: "/user" matches + * that path only, "/v2/public/*" matches every path under the prefix. + * Empty means any path. At least one of methods and paths must be set. + * + * @generated from field: repeated string paths = 3; + */ + paths: string[]; +}; + +/** + * Describes the message sam.v1.HTTPGrant. + * Use `create(HTTPGrantSchema)` to create a new message. + */ +export const HTTPGrantSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 16); + +/** + * EgressDestination is a destination outside the mesh that selected nodes + * serve as egress://. It is part of the policy document the admin + * writes; a node receives the destinations that select it at GET /egress + * and serves them without configuration of its own. + * + * @generated from message sam.v1.EgressDestination + */ +export type EgressDestination = Message<"sam.v1.EgressDestination"> & { + /** + * The destination hostname, lowercase, without a port or a path. It is the + * service name in grants (egress://) and the DHT key. + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Where the serving node forwards requests. Optional; https:// when + * empty. Must not carry a credential. + * + * @generated from field: string target_url = 2; + */ + targetUrl: string; + + /** + * Name of the credential the serving node presents upstream, resolved by + * the node from its secrets directory. Never a value: secret material does + * not travel through this API. + * + * @generated from field: string credential = 3; + */ + credential: string; + + /** + * Role names or key=value labels selecting the nodes that serve this + * destination. A node matches when any entry names one of its roles or + * labels. The control plane also grants the destination to the selected + * nodes, so the serving node authorizes local requests with its own + * credential; other callers need the grant on their own role. + * + * @generated from field: repeated string served_by = 4; + */ + servedBy: string[]; +}; + +/** + * Describes the message sam.v1.EgressDestination. + * Use `create(EgressDestinationSchema)` to create a new message. + */ +export const EgressDestinationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 17); + /** * @generated from message sam.v1.PolicyBinding */ @@ -754,7 +856,7 @@ export type PolicyBinding = Message<"sam.v1.PolicyBinding"> & { * Use `create(PolicyBindingSchema)` to create a new message. */ export const PolicyBindingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 16); + messageDesc(file_sam, 18); /** * PolicyConfig is the mesh policy as the operator writes it: roles and @@ -774,6 +876,11 @@ export type PolicyConfig = Message<"sam.v1.PolicyConfig"> & { * @generated from field: repeated sam.v1.PolicyBinding bindings = 2; */ bindings: PolicyBinding[]; + + /** + * @generated from field: repeated sam.v1.EgressDestination egress = 3; + */ + egress: EgressDestination[]; }; /** @@ -781,7 +888,7 @@ export type PolicyConfig = Message<"sam.v1.PolicyConfig"> & { * Use `create(PolicyConfigSchema)` to create a new message. */ export const PolicyConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 17); + messageDesc(file_sam, 19); /** * @generated from message sam.v1.PolicyConfigGetRequest @@ -794,7 +901,7 @@ export type PolicyConfigGetRequest = Message<"sam.v1.PolicyConfigGetRequest"> & * Use `create(PolicyConfigGetRequestSchema)` to create a new message. */ export const PolicyConfigGetRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 18); + messageDesc(file_sam, 20); /** * PolicyConfigGetResponse answers GET /policies for a mesh member holding a @@ -817,7 +924,7 @@ export type PolicyConfigGetResponse = Message<"sam.v1.PolicyConfigGetResponse"> * Use `create(PolicyConfigGetResponseSchema)` to create a new message. */ export const PolicyConfigGetResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 19); + messageDesc(file_sam, 21); /** * @generated from message sam.v1.PolicyConfigUpdateResponse @@ -839,7 +946,42 @@ export type PolicyConfigUpdateResponse = Message<"sam.v1.PolicyConfigUpdateRespo * Use `create(PolicyConfigUpdateResponseSchema)` to create a new message. */ export const PolicyConfigUpdateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 20); + messageDesc(file_sam, 22); + +/** + * @generated from message sam.v1.EgressAssignmentsRequest + */ +export type EgressAssignmentsRequest = Message<"sam.v1.EgressAssignmentsRequest"> & { +}; + +/** + * Describes the message sam.v1.EgressAssignmentsRequest. + * Use `create(EgressAssignmentsRequestSchema)` to create a new message. + */ +export const EgressAssignmentsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 23); + +/** + * EgressAssignmentsResponse answers GET /egress for a mesh member holding a + * biscuit: the destinations whose served_by selects that node. It is a + * separate endpoint from GET /policies so that a node predating it keeps + * syncing rules unchanged. + * + * @generated from message sam.v1.EgressAssignmentsResponse + */ +export type EgressAssignmentsResponse = Message<"sam.v1.EgressAssignmentsResponse"> & { + /** + * @generated from field: repeated sam.v1.EgressDestination egress = 1; + */ + egress: EgressDestination[]; +}; + +/** + * Describes the message sam.v1.EgressAssignmentsResponse. + * Use `create(EgressAssignmentsResponseSchema)` to create a new message. + */ +export const EgressAssignmentsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_sam, 24); /** * @generated from message sam.v1.KeysResponse @@ -874,7 +1016,7 @@ export type KeysResponse = Message<"sam.v1.KeysResponse"> & { * Use `create(KeysResponseSchema)` to create a new message. */ export const KeysResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 21); + messageDesc(file_sam, 25); /** * @generated from message sam.v1.TokenRefreshRequest @@ -917,7 +1059,7 @@ export type TokenRefreshRequest = Message<"sam.v1.TokenRefreshRequest"> & { * Use `create(TokenRefreshRequestSchema)` to create a new message. */ export const TokenRefreshRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 22); + messageDesc(file_sam, 26); /** * @generated from message sam.v1.TokenRefreshResponse @@ -944,7 +1086,7 @@ export type TokenRefreshResponse = Message<"sam.v1.TokenRefreshResponse"> & { * Use `create(TokenRefreshResponseSchema)` to create a new message. */ export const TokenRefreshResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 23); + messageDesc(file_sam, 27); /** * NodeCatalogReport is the body of POST /nodes/catalog: a node's @@ -966,7 +1108,7 @@ export type NodeCatalogReport = Message<"sam.v1.NodeCatalogReport"> & { * Use `create(NodeCatalogReportSchema)` to create a new message. */ export const NodeCatalogReportSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 24); + messageDesc(file_sam, 28); /** * @generated from message sam.v1.TokenRevokeRequest @@ -983,7 +1125,7 @@ export type TokenRevokeRequest = Message<"sam.v1.TokenRevokeRequest"> & { * Use `create(TokenRevokeRequestSchema)` to create a new message. */ export const TokenRevokeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 25); + messageDesc(file_sam, 29); /** * @generated from message sam.v1.TokenRevokeResponse @@ -1005,7 +1147,7 @@ export type TokenRevokeResponse = Message<"sam.v1.TokenRevokeResponse"> & { * Use `create(TokenRevokeResponseSchema)` to create a new message. */ export const TokenRevokeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 26); + messageDesc(file_sam, 30); /** * AgentSecret configures credential injection for one destination. It carries @@ -1044,7 +1186,7 @@ export type AgentSecret = Message<"sam.v1.AgentSecret"> & { * Use `create(AgentSecretSchema)` to create a new message. */ export const AgentSecretSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 27); + messageDesc(file_sam, 31); /** * AgentEgress is deny-by-default. Patterns are matched against the destination @@ -1069,7 +1211,7 @@ export type AgentEgress = Message<"sam.v1.AgentEgress"> & { * Use `create(AgentEgressSchema)` to create a new message. */ export const AgentEgressSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 28); + messageDesc(file_sam, 32); /** * AgentIngress declares that the agent serves a mesh service. The name is the @@ -1105,7 +1247,7 @@ export type AgentIngress = Message<"sam.v1.AgentIngress"> & { * Use `create(AgentIngressSchema)` to create a new message. */ export const AgentIngressSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 29); + messageDesc(file_sam, 33); /** * AgentBundle is everything the platform declares about one agent. Its @@ -1164,7 +1306,7 @@ export type AgentBundle = Message<"sam.v1.AgentBundle"> & { * Use `create(AgentBundleSchema)` to create a new message. */ export const AgentBundleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 30); + messageDesc(file_sam, 34); /** * AgentAttachRequest admits an agent. It is idempotent on agent_id: resuming @@ -1184,7 +1326,7 @@ export type AgentAttachRequest = Message<"sam.v1.AgentAttachRequest"> & { * Use `create(AgentAttachRequestSchema)` to create a new message. */ export const AgentAttachRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 31); + messageDesc(file_sam, 35); /** * @generated from message sam.v1.AgentAttachResponse @@ -1215,7 +1357,7 @@ export type AgentAttachResponse = Message<"sam.v1.AgentAttachResponse"> & { * Use `create(AgentAttachResponseSchema)` to create a new message. */ export const AgentAttachResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 32); + messageDesc(file_sam, 36); /** * AgentDetachRequest stops an agent: ingress is unregistered, channels are @@ -1235,7 +1377,7 @@ export type AgentDetachRequest = Message<"sam.v1.AgentDetachRequest"> & { * Use `create(AgentDetachRequestSchema)` to create a new message. */ export const AgentDetachRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 33); + messageDesc(file_sam, 37); /** * @generated from message sam.v1.AgentDetachResponse @@ -1257,7 +1399,7 @@ export type AgentDetachResponse = Message<"sam.v1.AgentDetachResponse"> & { * Use `create(AgentDetachResponseSchema)` to create a new message. */ export const AgentDetachResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 34); + messageDesc(file_sam, 38); /** * AgentRefreshRequest hands in a rotated workload credential. Platforms rotate @@ -1283,7 +1425,7 @@ export type AgentRefreshRequest = Message<"sam.v1.AgentRefreshRequest"> & { * Use `create(AgentRefreshRequestSchema)` to create a new message. */ export const AgentRefreshRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 35); + messageDesc(file_sam, 39); /** * @generated from message sam.v1.AgentRefreshResponse @@ -1310,7 +1452,7 @@ export type AgentRefreshResponse = Message<"sam.v1.AgentRefreshResponse"> & { * Use `create(AgentRefreshResponseSchema)` to create a new message. */ export const AgentRefreshResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 36); + messageDesc(file_sam, 40); /** * AgentStatusRequest reports on one agent, or on all of them when agent_id is @@ -1330,7 +1472,7 @@ export type AgentStatusRequest = Message<"sam.v1.AgentStatusRequest"> & { * Use `create(AgentStatusRequestSchema)` to create a new message. */ export const AgentStatusRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 37); + messageDesc(file_sam, 41); /** * @generated from message sam.v1.AgentStatus @@ -1362,7 +1504,7 @@ export type AgentStatus = Message<"sam.v1.AgentStatus"> & { * Use `create(AgentStatusSchema)` to create a new message. */ export const AgentStatusSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 38); + messageDesc(file_sam, 42); /** * @generated from message sam.v1.AgentStatusResponse @@ -1384,7 +1526,7 @@ export type AgentStatusResponse = Message<"sam.v1.AgentStatusResponse"> & { * Use `create(AgentStatusResponseSchema)` to create a new message. */ export const AgentStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 39); + messageDesc(file_sam, 43); /** * @generated from message sam.v1.IdentityEvidenceResponse @@ -1428,7 +1570,7 @@ export type IdentityEvidenceResponse = Message<"sam.v1.IdentityEvidenceResponse" * Use `create(IdentityEvidenceResponseSchema)` to create a new message. */ export const IdentityEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 40); + messageDesc(file_sam, 44); /** * @generated from message sam.v1.PeerEvidenceResponse @@ -1484,7 +1626,7 @@ export type PeerEvidenceResponse = Message<"sam.v1.PeerEvidenceResponse"> & { * Use `create(PeerEvidenceResponseSchema)` to create a new message. */ export const PeerEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 41); + messageDesc(file_sam, 45); /** * @generated from message sam.v1.MemberCredential @@ -1549,7 +1691,7 @@ export type MemberCredential = Message<"sam.v1.MemberCredential"> & { * Use `create(MemberCredentialSchema)` to create a new message. */ export const MemberCredentialSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 42); + messageDesc(file_sam, 46); /** * @generated from message sam.v1.TrustedSigningKey @@ -1576,7 +1718,7 @@ export type TrustedSigningKey = Message<"sam.v1.TrustedSigningKey"> & { * Use `create(TrustedSigningKeySchema)` to create a new message. */ export const TrustedSigningKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 43); + messageDesc(file_sam, 47); /** * @generated from message sam.v1.OIDCSession @@ -1608,7 +1750,7 @@ export type OIDCSession = Message<"sam.v1.OIDCSession"> & { * Use `create(OIDCSessionSchema)` to create a new message. */ export const OIDCSessionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 44); + messageDesc(file_sam, 48); /** * @generated from enum sam.v1.EnrollmentStatus @@ -1664,6 +1806,17 @@ export enum ServiceType { * @generated from enum value: SERVICE_TYPE_A2A = 3; */ A2A = 3, + + /** + * A destination outside the mesh, reached through a node that enforces + * policy on it. The service name is the destination hostname, so a grant + * reads egress://api.github.com and the request fact + * service("egress", "api.github.com"). Egress names have no .sam.alt form: + * a sandboxed agent connects to the destination name itself. + * + * @generated from enum value: SERVICE_TYPE_EGRESS = 4; + */ + EGRESS = 4, } /** diff --git a/sdk/python/src/agent_mesh/_gen/datalog.json b/sdk/python/src/agent_mesh/_gen/datalog.json index f7d67abf..ff958550 100644 --- a/sdk/python/src/agent_mesh/_gen/datalog.json +++ b/sdk/python/src/agent_mesh/_gen/datalog.json @@ -16,6 +16,18 @@ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_all($fact)", "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_all_facts(true)" ], + "http_rules": [ + "http_method_ok($t, $k) <- method($m), granted_method($t, $k, $set), $set.contains($m)", + "http_method_ok($t, $k) <- granted_method_any($t, $k)", + "http_path_ok($t, $k) <- path($p), granted_path_exact($t, $k, $set), $set.contains($p)", + "http_path_ok($t, $k) <- path($p), granted_path_prefix($t, $k, $prefix), $p.starts_with($prefix)", + "http_path_ok($t, $k) <- granted_path_any($t, $k)", + "granted_service_exact($t, $n) <- service($t, $n), http_granted_service_exact($t, $n), http_method_ok($t, $n), http_path_ok($t, $n)", + "granted_service_suffix($t, $s) <- service($t, $n), http_granted_service_suffix($t, $s), $n.ends_with($s), http_method_ok($t, $s), http_path_ok($t, $s)", + "granted_service_prefix($t, $p) <- service($t, $n), http_granted_service_prefix($t, $p), $n.starts_with($p), http_method_ok($t, $p), http_path_ok($t, $p)", + "granted_service_all($t) <- service($t, $n), http_granted_service_all($t), http_method_ok($t, \"*\"), http_path_ok($t, \"*\")", + "granted_service_all_types(true) <- service($t, $n), http_granted_service_all_types(true), http_method_ok(\"*\", \"*\"), http_path_ok(\"*\", \"*\")" + ], "agent_rules": [ "agent_authorized(true) <- agent($a), granted_agent_exact($a)", "agent_authorized(true) <- agent($a), granted_agent_set($set), $set.contains($a)", @@ -38,6 +50,8 @@ "fact_service": "service", "fact_connection_peer_id": "connection_peer_id", "fact_agent": "agent", + "fact_method": "method", + "fact_path": "path", "fact_time": "time", "fact_role": "role", "fact_target_fact": "target_fact", diff --git a/sdk/python/src/agent_mesh/_proto/sam_pb2.py b/sdk/python/src/agent_mesh/_proto/sam_pb2.py index 05f2781d..a0ac4432 100644 --- a/sdk/python/src/agent_mesh/_proto/sam_pb2.py +++ b/sdk/python/src/agent_mesh/_proto/sam_pb2.py @@ -14,7 +14,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tsam.proto\x12\x06sam.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"C\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\x12\r\n\x05\x61gent\x18\x03 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xd6\x01\n\tMeshEvent\x12$\n\x04type\x18\x01 \x01(\x0e\x32\x16.sam.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf3\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x31\n\x06labels\x18\x05 \x03(\x0b\x32!.sam.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x91\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12:\n\x06labels\x18\x05 \x03(\x0b\x32*.sam.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xfd\x01\n\x17\x42ootstrapEnrollResponse\x12(\n\x06status\x18\x01 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"S\n\x0bServiceInfo\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"{\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12,\n\x03\x65nv\x18\x02 \x03(\x0b\x32\x1f.sam.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8a\x01\n\x16RegisterServiceRequest\x12$\n\x07service\x18\x01 \x01(\x0b\x32\x13.sam.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12)\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x16.sam.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xb2\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x33\n\x06labels\x18\x05 \x03(\x0b\x32#.sam.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\"\xac\x01\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x95\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_agents\x18\x05 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x06 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"Z\n\x0cPolicyConfig\x12!\n\x05roles\x18\x01 \x03(\x0b\x32\x12.sam.v1.PolicyRole\x12\'\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x15.sam.v1.PolicyBinding\"\x18\n\x16PolicyConfigGetRequest\"M\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x03 \x03(\tJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\x05rolesR\x08\x62indings\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"^\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\":\n\x11NodeCatalogReport\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"R\n\x0b\x41gentSecret\x12\x0c\n\x04host\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x13\n\x0bheader_name\x18\x03 \x01(\t\x12\x12\n\nvalue_path\x18\x04 \x01(\t\"B\n\x0b\x41gentEgress\x12\r\n\x05\x61llow\x18\x01 \x03(\t\x12$\n\x07secrets\x18\x02 \x03(\x0b\x32\x13.sam.v1.AgentSecret\"b\n\x0c\x41gentIngress\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\"\xaa\x01\n\x0b\x41gentBundle\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x10\n\x08\x61gent_id\x18\x02 \x01(\t\x12\x13\n\x0b\x65xternal_id\x18\x03 \x01(\t\x12\x17\n\x0f\x63redential_path\x18\x04 \x01(\t\x12#\n\x06\x65gress\x18\x05 \x01(\x0b\x32\x13.sam.v1.AgentEgress\x12%\n\x07ingress\x18\x06 \x03(\x0b\x32\x14.sam.v1.AgentIngress\"9\n\x12\x41gentAttachRequest\x12#\n\x06\x62undle\x18\x01 \x01(\x0b\x32\x13.sam.v1.AgentBundle\"S\n\x13\x41gentAttachResponse\x12\x15\n\regress_socket\x18\x01 \x01(\t\x12\x16\n\x0eingress_socket\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\"&\n\x12\x41gentDetachRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\"5\n\x13\x41gentDetachResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"@\n\x13\x41gentRefreshRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x17\n\x0f\x63redential_path\x18\x02 \x01(\t\"g\n\x14\x41gentRefreshResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"&\n\x12\x41gentStatusRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\"\x94\x01\n\x0b\x41gentStatus\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x10\n\x08\x61ttached\x18\x02 \x01(\x08\x12%\n\x07ingress\x18\x03 \x03(\x0b\x32\x14.sam.v1.AgentIngress\x12:\n\x16\x63redential_expire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"I\n\x13\x41gentStatusResponse\x12#\n\x06\x61gents\x18\x01 \x03(\x0b\x32\x13.sam.v1.AgentStatus\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc0\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x38\n\x06labels\x18\x05 \x03(\x0b\x32(.sam.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x80\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x19.sam.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12)\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x13.sam.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*s\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x42\x1bZ\x19github.com/google/sam/apib\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tsam.proto\x12\x06sam.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"C\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\x12\r\n\x05\x61gent\x18\x03 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xd6\x01\n\tMeshEvent\x12$\n\x04type\x18\x01 \x01(\x0e\x32\x16.sam.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf3\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x31\n\x06labels\x18\x05 \x03(\x0b\x32!.sam.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x91\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12:\n\x06labels\x18\x05 \x03(\x0b\x32*.sam.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xfd\x01\n\x17\x42ootstrapEnrollResponse\x12(\n\x06status\x18\x01 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"S\n\x0bServiceInfo\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"{\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12,\n\x03\x65nv\x18\x02 \x03(\x0b\x32\x1f.sam.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8a\x01\n\x16RegisterServiceRequest\x12$\n\x07service\x18\x01 \x01(\x0b\x32\x13.sam.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12)\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x16.sam.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xb2\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x33\n\x06labels\x18\x05 \x03(\x0b\x32#.sam.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\"\xac\x01\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xb6\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_agents\x18\x05 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x06 \x03(\t\x12\x1f\n\x04http\x18\x07 \x03(\x0b\x32\x11.sam.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\\\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x85\x01\n\x0cPolicyConfig\x12!\n\x05roles\x18\x01 \x03(\x0b\x32\x12.sam.v1.PolicyRole\x12\'\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x15.sam.v1.PolicyBinding\x12)\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"M\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x03 \x03(\tJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\x05rolesR\x08\x62indings\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"F\n\x19\x45gressAssignmentsResponse\x12)\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"^\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\":\n\x11NodeCatalogReport\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"R\n\x0b\x41gentSecret\x12\x0c\n\x04host\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x13\n\x0bheader_name\x18\x03 \x01(\t\x12\x12\n\nvalue_path\x18\x04 \x01(\t\"B\n\x0b\x41gentEgress\x12\r\n\x05\x61llow\x18\x01 \x03(\t\x12$\n\x07secrets\x18\x02 \x03(\x0b\x32\x13.sam.v1.AgentSecret\"b\n\x0c\x41gentIngress\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\"\xaa\x01\n\x0b\x41gentBundle\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x10\n\x08\x61gent_id\x18\x02 \x01(\t\x12\x13\n\x0b\x65xternal_id\x18\x03 \x01(\t\x12\x17\n\x0f\x63redential_path\x18\x04 \x01(\t\x12#\n\x06\x65gress\x18\x05 \x01(\x0b\x32\x13.sam.v1.AgentEgress\x12%\n\x07ingress\x18\x06 \x03(\x0b\x32\x14.sam.v1.AgentIngress\"9\n\x12\x41gentAttachRequest\x12#\n\x06\x62undle\x18\x01 \x01(\x0b\x32\x13.sam.v1.AgentBundle\"S\n\x13\x41gentAttachResponse\x12\x15\n\regress_socket\x18\x01 \x01(\t\x12\x16\n\x0eingress_socket\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\"&\n\x12\x41gentDetachRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\"5\n\x13\x41gentDetachResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"@\n\x13\x41gentRefreshRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x17\n\x0f\x63redential_path\x18\x02 \x01(\t\"g\n\x14\x41gentRefreshResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"&\n\x12\x41gentStatusRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\"\x94\x01\n\x0b\x41gentStatus\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x10\n\x08\x61ttached\x18\x02 \x01(\x08\x12%\n\x07ingress\x18\x03 \x03(\x0b\x32\x14.sam.v1.AgentIngress\x12:\n\x16\x63redential_expire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"I\n\x13\x41gentStatusResponse\x12#\n\x06\x61gents\x18\x01 \x03(\x0b\x32\x13.sam.v1.AgentStatus\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc0\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x38\n\x06labels\x18\x05 \x03(\x0b\x32(.sam.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x80\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x19.sam.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12)\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x13.sam.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04\x42\x1bZ\x19github.com/google/sam/apib\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sam_pb2', globals()) @@ -32,10 +32,10 @@ _SERVICEANNOUNCE_LABELSENTRY._serialized_options = b'8\001' _PEEREVIDENCERESPONSE_LABELSENTRY._options = None _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_options = b'8\001' - _ENROLLMENTSTATUS._serialized_start=5570 - _ENROLLMENTSTATUS._serialized_end=5718 - _SERVICETYPE._serialized_start=5720 - _SERVICETYPE._serialized_end=5835 + _ENROLLMENTSTATUS._serialized_start=5903 + _ENROLLMENTSTATUS._serialized_end=6051 + _SERVICETYPE._serialized_start=6054 + _SERVICETYPE._serialized_end=6194 _AUTHFRAME._serialized_start=54 _AUTHFRAME._serialized_end=121 _AUTHRESPONSE._serialized_start=123 @@ -77,65 +77,73 @@ _ROUTERLEASERESPONSE._serialized_start=2437 _ROUTERLEASERESPONSE._serialized_end=2539 _POLICYROLE._serialized_start=2542 - _POLICYROLE._serialized_end=2691 - _POLICYBINDING._serialized_start=2693 - _POLICYBINDING._serialized_end=2739 - _POLICYCONFIG._serialized_start=2741 - _POLICYCONFIG._serialized_end=2831 - _POLICYCONFIGGETREQUEST._serialized_start=2833 - _POLICYCONFIGGETREQUEST._serialized_end=2857 - _POLICYCONFIGGETRESPONSE._serialized_start=2859 - _POLICYCONFIGGETRESPONSE._serialized_end=2936 - _POLICYCONFIGUPDATERESPONSE._serialized_start=2938 - _POLICYCONFIGUPDATERESPONSE._serialized_end=2998 - _KEYSRESPONSE._serialized_start=3000 - _KEYSRESPONSE._serialized_end=3102 - _TOKENREFRESHREQUEST._serialized_start=3104 - _TOKENREFRESHREQUEST._serialized_end=3198 - _TOKENREFRESHRESPONSE._serialized_start=3200 - _TOKENREFRESHRESPONSE._serialized_end=3317 - _NODECATALOGREPORT._serialized_start=3319 - _NODECATALOGREPORT._serialized_end=3377 - _TOKENREVOKEREQUEST._serialized_start=3379 - _TOKENREVOKEREQUEST._serialized_end=3416 - _TOKENREVOKERESPONSE._serialized_start=3418 - _TOKENREVOKERESPONSE._serialized_end=3471 - _AGENTSECRET._serialized_start=3473 - _AGENTSECRET._serialized_end=3555 - _AGENTEGRESS._serialized_start=3557 - _AGENTEGRESS._serialized_end=3623 - _AGENTINGRESS._serialized_start=3625 - _AGENTINGRESS._serialized_end=3723 - _AGENTBUNDLE._serialized_start=3726 - _AGENTBUNDLE._serialized_end=3896 - _AGENTATTACHREQUEST._serialized_start=3898 - _AGENTATTACHREQUEST._serialized_end=3955 - _AGENTATTACHRESPONSE._serialized_start=3957 - _AGENTATTACHRESPONSE._serialized_end=4040 - _AGENTDETACHREQUEST._serialized_start=4042 - _AGENTDETACHREQUEST._serialized_end=4080 - _AGENTDETACHRESPONSE._serialized_start=4082 - _AGENTDETACHRESPONSE._serialized_end=4135 - _AGENTREFRESHREQUEST._serialized_start=4137 - _AGENTREFRESHREQUEST._serialized_end=4201 - _AGENTREFRESHRESPONSE._serialized_start=4203 - _AGENTREFRESHRESPONSE._serialized_end=4306 - _AGENTSTATUSREQUEST._serialized_start=4308 - _AGENTSTATUSREQUEST._serialized_end=4346 - _AGENTSTATUS._serialized_start=4349 - _AGENTSTATUS._serialized_end=4497 - _AGENTSTATUSRESPONSE._serialized_start=4499 - _AGENTSTATUSRESPONSE._serialized_end=4572 - _IDENTITYEVIDENCERESPONSE._serialized_start=4575 - _IDENTITYEVIDENCERESPONSE._serialized_end=4803 - _PEEREVIDENCERESPONSE._serialized_start=4806 - _PEEREVIDENCERESPONSE._serialized_end=5126 + _POLICYROLE._serialized_end=2724 + _HTTPGRANT._serialized_start=2726 + _HTTPGRANT._serialized_end=2786 + _EGRESSDESTINATION._serialized_start=2788 + _EGRESSDESTINATION._serialized_end=2880 + _POLICYBINDING._serialized_start=2882 + _POLICYBINDING._serialized_end=2928 + _POLICYCONFIG._serialized_start=2931 + _POLICYCONFIG._serialized_end=3064 + _POLICYCONFIGGETREQUEST._serialized_start=3066 + _POLICYCONFIGGETREQUEST._serialized_end=3090 + _POLICYCONFIGGETRESPONSE._serialized_start=3092 + _POLICYCONFIGGETRESPONSE._serialized_end=3169 + _POLICYCONFIGUPDATERESPONSE._serialized_start=3171 + _POLICYCONFIGUPDATERESPONSE._serialized_end=3231 + _EGRESSASSIGNMENTSREQUEST._serialized_start=3233 + _EGRESSASSIGNMENTSREQUEST._serialized_end=3259 + _EGRESSASSIGNMENTSRESPONSE._serialized_start=3261 + _EGRESSASSIGNMENTSRESPONSE._serialized_end=3331 + _KEYSRESPONSE._serialized_start=3333 + _KEYSRESPONSE._serialized_end=3435 + _TOKENREFRESHREQUEST._serialized_start=3437 + _TOKENREFRESHREQUEST._serialized_end=3531 + _TOKENREFRESHRESPONSE._serialized_start=3533 + _TOKENREFRESHRESPONSE._serialized_end=3650 + _NODECATALOGREPORT._serialized_start=3652 + _NODECATALOGREPORT._serialized_end=3710 + _TOKENREVOKEREQUEST._serialized_start=3712 + _TOKENREVOKEREQUEST._serialized_end=3749 + _TOKENREVOKERESPONSE._serialized_start=3751 + _TOKENREVOKERESPONSE._serialized_end=3804 + _AGENTSECRET._serialized_start=3806 + _AGENTSECRET._serialized_end=3888 + _AGENTEGRESS._serialized_start=3890 + _AGENTEGRESS._serialized_end=3956 + _AGENTINGRESS._serialized_start=3958 + _AGENTINGRESS._serialized_end=4056 + _AGENTBUNDLE._serialized_start=4059 + _AGENTBUNDLE._serialized_end=4229 + _AGENTATTACHREQUEST._serialized_start=4231 + _AGENTATTACHREQUEST._serialized_end=4288 + _AGENTATTACHRESPONSE._serialized_start=4290 + _AGENTATTACHRESPONSE._serialized_end=4373 + _AGENTDETACHREQUEST._serialized_start=4375 + _AGENTDETACHREQUEST._serialized_end=4413 + _AGENTDETACHRESPONSE._serialized_start=4415 + _AGENTDETACHRESPONSE._serialized_end=4468 + _AGENTREFRESHREQUEST._serialized_start=4470 + _AGENTREFRESHREQUEST._serialized_end=4534 + _AGENTREFRESHRESPONSE._serialized_start=4536 + _AGENTREFRESHRESPONSE._serialized_end=4639 + _AGENTSTATUSREQUEST._serialized_start=4641 + _AGENTSTATUSREQUEST._serialized_end=4679 + _AGENTSTATUS._serialized_start=4682 + _AGENTSTATUS._serialized_end=4830 + _AGENTSTATUSRESPONSE._serialized_start=4832 + _AGENTSTATUSRESPONSE._serialized_end=4905 + _IDENTITYEVIDENCERESPONSE._serialized_start=4908 + _IDENTITYEVIDENCERESPONSE._serialized_end=5136 + _PEEREVIDENCERESPONSE._serialized_start=5139 + _PEEREVIDENCERESPONSE._serialized_end=5459 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_start=604 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_end=649 - _MEMBERCREDENTIAL._serialized_start=5129 - _MEMBERCREDENTIAL._serialized_end=5385 - _TRUSTEDSIGNINGKEY._serialized_start=5387 - _TRUSTEDSIGNINGKEY._serialized_end=5476 - _OIDCSESSION._serialized_start=5478 - _OIDCSESSION._serialized_end=5567 + _MEMBERCREDENTIAL._serialized_start=5462 + _MEMBERCREDENTIAL._serialized_end=5718 + _TRUSTEDSIGNINGKEY._serialized_start=5720 + _TRUSTEDSIGNINGKEY._serialized_end=5809 + _OIDCSESSION._serialized_start=5811 + _OIDCSESSION._serialized_end=5900 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi b/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi index ce8a269b..b172eb28 100644 --- a/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi +++ b/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi @@ -11,6 +11,7 @@ ENROLLMENT_STATUS_PENDING: EnrollmentStatus ENROLLMENT_STATUS_REJECTED: EnrollmentStatus ENROLLMENT_STATUS_UNSPECIFIED: EnrollmentStatus SERVICE_TYPE_A2A: ServiceType +SERVICE_TYPE_EGRESS: ServiceType SERVICE_TYPE_INFERENCE: ServiceType SERVICE_TYPE_MCP: ServiceType SERVICE_TYPE_UNSPECIFIED: ServiceType @@ -241,6 +242,28 @@ class DiscoveredProvider(_message.Message): srv_name: str def __init__(self, peer_id: _Optional[str] = ..., local_proxy_url: _Optional[str] = ..., srv_name: _Optional[str] = ..., srv_description: _Optional[str] = ...) -> None: ... +class EgressAssignmentsRequest(_message.Message): + __slots__ = [] + def __init__(self) -> None: ... + +class EgressAssignmentsResponse(_message.Message): + __slots__ = ["egress"] + EGRESS_FIELD_NUMBER: _ClassVar[int] + egress: _containers.RepeatedCompositeFieldContainer[EgressDestination] + def __init__(self, egress: _Optional[_Iterable[_Union[EgressDestination, _Mapping]]] = ...) -> None: ... + +class EgressDestination(_message.Message): + __slots__ = ["credential", "name", "served_by", "target_url"] + CREDENTIAL_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + SERVED_BY_FIELD_NUMBER: _ClassVar[int] + TARGET_URL_FIELD_NUMBER: _ClassVar[int] + credential: str + name: str + served_by: _containers.RepeatedScalarFieldContainer[str] + target_url: str + def __init__(self, name: _Optional[str] = ..., target_url: _Optional[str] = ..., credential: _Optional[str] = ..., served_by: _Optional[_Iterable[str]] = ...) -> None: ... + class EnrollRequest(_message.Message): __slots__ = ["challenge_signature", "challenge_unix_ms", "jwt", "labels", "peer_id", "public_key", "requested_role"] class LabelsEntry(_message.Message): @@ -280,6 +303,16 @@ class EnrollResponse(_message.Message): router_addresses: _containers.RepeatedScalarFieldContainer[str] def __init__(self, biscuit_token: _Optional[bytes] = ..., error_message: _Optional[str] = ..., control_plane_public_key: _Optional[bytes] = ..., router_addresses: _Optional[_Iterable[str]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... +class HTTPGrant(_message.Message): + __slots__ = ["methods", "paths", "service"] + METHODS_FIELD_NUMBER: _ClassVar[int] + PATHS_FIELD_NUMBER: _ClassVar[int] + SERVICE_FIELD_NUMBER: _ClassVar[int] + methods: _containers.RepeatedScalarFieldContainer[str] + paths: _containers.RepeatedScalarFieldContainer[str] + service: str + def __init__(self, service: _Optional[str] = ..., methods: _Optional[_Iterable[str]] = ..., paths: _Optional[_Iterable[str]] = ...) -> None: ... + class IdentityEvidenceResponse(_message.Message): __slots__ = ["biscuit", "biscuit_expire_time", "check_time", "control_plane_url", "peer_id", "trusted_control_plane_keys"] BISCUIT_EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] @@ -397,12 +430,14 @@ class PolicyBinding(_message.Message): def __init__(self, role: _Optional[str] = ..., members: _Optional[_Iterable[str]] = ...) -> None: ... class PolicyConfig(_message.Message): - __slots__ = ["bindings", "roles"] + __slots__ = ["bindings", "egress", "roles"] BINDINGS_FIELD_NUMBER: _ClassVar[int] + EGRESS_FIELD_NUMBER: _ClassVar[int] ROLES_FIELD_NUMBER: _ClassVar[int] bindings: _containers.RepeatedCompositeFieldContainer[PolicyBinding] + egress: _containers.RepeatedCompositeFieldContainer[EgressDestination] roles: _containers.RepeatedCompositeFieldContainer[PolicyRole] - def __init__(self, roles: _Optional[_Iterable[_Union[PolicyRole, _Mapping]]] = ..., bindings: _Optional[_Iterable[_Union[PolicyBinding, _Mapping]]] = ...) -> None: ... + def __init__(self, roles: _Optional[_Iterable[_Union[PolicyRole, _Mapping]]] = ..., bindings: _Optional[_Iterable[_Union[PolicyBinding, _Mapping]]] = ..., egress: _Optional[_Iterable[_Union[EgressDestination, _Mapping]]] = ...) -> None: ... class PolicyConfigGetRequest(_message.Message): __slots__ = [] @@ -423,20 +458,22 @@ class PolicyConfigUpdateResponse(_message.Message): def __init__(self, success: bool = ..., error: _Optional[str] = ...) -> None: ... class PolicyRole(_message.Message): - __slots__ = ["allowed_agents", "allowed_labels", "allowed_services", "allowed_targets", "custom_datalog", "name"] + __slots__ = ["allowed_agents", "allowed_labels", "allowed_services", "allowed_targets", "custom_datalog", "http", "name"] ALLOWED_AGENTS_FIELD_NUMBER: _ClassVar[int] ALLOWED_LABELS_FIELD_NUMBER: _ClassVar[int] ALLOWED_SERVICES_FIELD_NUMBER: _ClassVar[int] ALLOWED_TARGETS_FIELD_NUMBER: _ClassVar[int] CUSTOM_DATALOG_FIELD_NUMBER: _ClassVar[int] + HTTP_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] allowed_agents: _containers.RepeatedScalarFieldContainer[str] allowed_labels: _containers.RepeatedScalarFieldContainer[str] allowed_services: _containers.RepeatedScalarFieldContainer[str] allowed_targets: _containers.RepeatedScalarFieldContainer[str] custom_datalog: _containers.RepeatedScalarFieldContainer[str] + http: _containers.RepeatedCompositeFieldContainer[HTTPGrant] name: str - def __init__(self, name: _Optional[str] = ..., allowed_targets: _Optional[_Iterable[str]] = ..., allowed_services: _Optional[_Iterable[str]] = ..., custom_datalog: _Optional[_Iterable[str]] = ..., allowed_agents: _Optional[_Iterable[str]] = ..., allowed_labels: _Optional[_Iterable[str]] = ...) -> None: ... + def __init__(self, name: _Optional[str] = ..., allowed_targets: _Optional[_Iterable[str]] = ..., allowed_services: _Optional[_Iterable[str]] = ..., custom_datalog: _Optional[_Iterable[str]] = ..., allowed_agents: _Optional[_Iterable[str]] = ..., allowed_labels: _Optional[_Iterable[str]] = ..., http: _Optional[_Iterable[_Union[HTTPGrant, _Mapping]]] = ...) -> None: ... class RegisterServiceRequest(_message.Message): __slots__ = ["command", "service", "target_url"] From 8d9672d0668cf4cb79e2b6bc240a06890f166faf Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 20:03:18 +0000 Subject: [PATCH 2/6] controlplane: validate, store and distribute egress destinations POST /policies accepts the egress section and the http field, validates them (a served_by entry is a role of the document or a key=value label; a credential is a name, not a path; an http entry names one of the role's allowed_services and narrows something) and stores them: http grants as protojson rows under the role, destinations in their own table. GET /policies renders one serving rule per served_by entry, granted_service_exact("egress", name) <- role(r) or <- label(k, v), so a serving node authorizes local requests with its own credential; the response carries no new field, so a node predating this keeps syncing. GET /egress answers a node with the destinations that select it, resolved from its enrolled role, the roles its identity binds to and its attested labels. The client reports a 404 as ErrNotFound so a newer node treats an older control plane as one that assigns nothing. --- internal/controlplane/client/client.go | 18 ++ internal/controlplane/egress_test.go | 258 +++++++++++++++++++++++++ internal/controlplane/server.go | 192 +++++++++++++++++- internal/controlplane/server_test.go | 4 +- internal/controlplane/ui.go | 6 +- internal/storage/mesh_policy_test.go | 57 ++++++ internal/storage/sql_store.go | 120 +++++++++++- internal/storage/storage.go | 11 ++ 8 files changed, 652 insertions(+), 14 deletions(-) create mode 100644 internal/controlplane/egress_test.go diff --git a/internal/controlplane/client/client.go b/internal/controlplane/client/client.go index 4aa46304..b9fc97e2 100644 --- a/internal/controlplane/client/client.go +++ b/internal/controlplane/client/client.go @@ -48,6 +48,11 @@ const MaxBodyBytes = 8 << 20 // truncated ban set or router list would be read as a smaller, valid one. var ErrBodyTooLarge = errors.New("control plane answer exceeds the body cap") +// ErrNotFound marks a 404: the control plane does not serve the endpoint, as +// one predating it does not. Callers of an endpoint added after the first +// release check for it, so a newer node works against an older control plane. +var ErrNotFound = errors.New("control plane does not serve this endpoint") + // ReadBody reads a control plane response body of at most MaxBodyBytes and // reports ErrBodyTooLarge for anything larger. func ReadBody(r io.Reader) ([]byte, error) { @@ -140,6 +145,16 @@ func (c *Client) FetchPolicy(ctx context.Context, biscuit []byte) (*api.PolicyCo return &policy, nil } +// FetchEgress is GET /egress, authenticated with the caller's biscuit: the +// egress destinations the control plane assigned to this node. +func (c *Client) FetchEgress(ctx context.Context, biscuit []byte) (*api.EgressAssignmentsResponse, error) { + var egress api.EgressAssignmentsResponse + if err := c.get(ctx, "/egress", biscuit, &egress); err != nil { + return nil, err + } + return &egress, nil +} + func (c *Client) get(ctx context.Context, path string, biscuit []byte, msg proto.Message) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { @@ -158,6 +173,9 @@ func (c *Client) get(ctx context.Context, path string, biscuit []byte, msg proto if err != nil { return fmt.Errorf("%s: %w", path, err) } + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("%w: %s", ErrNotFound, path) + } if resp.StatusCode != http.StatusOK { return fmt.Errorf("control plane returned status %s: %s", resp.Status, string(body)) } diff --git a/internal/controlplane/egress_test.go b/internal/controlplane/egress_test.go new file mode 100644 index 00000000..68ffadc2 --- /dev/null +++ b/internal/controlplane/egress_test.go @@ -0,0 +1,258 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/proto" +) + +// TestEgressPolicyIsDistributedToServingNodes covers the admin-to-node path +// of an egress destination: the policy document is accepted and stored with +// its http and egress sections, GET /policies renders the serving grants, +// and GET /egress hands each node the destinations that select it, by role +// or by label, and nothing else. +func TestEgressPolicyIsDistributedToServingNodes(t *testing.T) { + issuer, _ := startCustomMockOIDC(t) + srv, store, baseURL := setupTestServer(t, issuer) + defer func() { + _ = srv.Close() + _ = store.Close() + }() + const adminToken = "super-secret-admin-token" + srv.config.AdminToken = adminToken + srv.config.AutoApproveEnrollment = true + ctx := context.Background() + client := &http.Client{Timeout: 5 * time.Second} + + // Bootstrap tokens need the roles to exist before the policy below is + // posted, and labels need a role that permits them. + if err := store.SaveMeshPolicy(ctx, []*api.PolicyRole{ + {Name: "pep", AllowedLabels: []string{"*"}}, + {Name: api.RoleNode, AllowedLabels: []string{"*"}}, + }, nil); err != nil { + t.Fatal(err) + } + + policy := `{ + "roles": [ + {"name": "pep", "allowed_services": ["egress://api.github.com"], "allowed_targets": ["*"], "allowed_labels": ["*"]}, + {"name": "sam:role:node", "allowed_services": ["egress://mam.internal.example.com"], "allowed_targets": ["*"], "allowed_labels": ["*"], + "http": [{"service": "egress://mam.internal.example.com", "methods": ["GET"], "paths": ["/v2/public/*"]}]} + ], + "bindings": [{"role": "sam:role:node", "members": ["sam:system:authenticated"]}], + "egress": [ + {"name": "api.github.com", "credential": "github-eu", "served_by": ["pep"]}, + {"name": "mam.internal.example.com", "target_url": "http://mam.internal.example.com:8080", "served_by": ["site=dc1"]} + ] + }` + postPolicy := func(t *testing.T, body string) (int, string) { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, baseURL+"/policies", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+adminToken) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("POST /policies: %v", err) + } + defer func() { _ = resp.Body.Close() }() + out, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(out) + } + if status, body := postPolicy(t, policy); status != http.StatusOK { + t.Fatalf("POST /policies: %d %s", status, body) + } + + // Stored and rendered back with both sections, so the console can post + // what it shows. + roles, _, err := store.GetMeshPolicy(ctx) + if err != nil { + t.Fatal(err) + } + var nodeRole *api.PolicyRole + for _, r := range roles { + if r.Name == api.RoleNode { + nodeRole = r + } + } + if nodeRole == nil || len(nodeRole.Http) != 1 || nodeRole.Http[0].Service != "egress://mam.internal.example.com" || !slices.Equal(nodeRole.Http[0].Methods, []string{"GET"}) { + t.Fatalf("http section was not stored: %v", nodeRole) + } + egress, err := store.GetEgressDestinations(ctx) + if err != nil { + t.Fatal(err) + } + if len(egress) != 2 || egress[0].Name != "api.github.com" || egress[0].Credential != "github-eu" || !slices.Equal(egress[1].ServedBy, []string{"site=dc1"}) { + t.Fatalf("egress section was not stored: %v", egress) + } + req, _ := http.NewRequest(http.MethodGet, baseURL+"/admin/policy", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + rendered, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if !strings.Contains(string(rendered), `"egress"`) || !strings.Contains(string(rendered), `"http"`) { + t.Fatalf("GET /admin/policy lacks the new sections: %s", rendered) + } + if status, body := postPolicy(t, string(rendered)); status != http.StatusOK { + t.Fatalf("re-posting the rendered policy: %d %s", status, body) + } + + // Two nodes: one holds the pep role, one is a plain node labelled + // site=dc1. Each is selected by exactly one destination. + enroll := func(t *testing.T, role string, labels map[string]string) []byte { + t.Helper() + token := createAdminBootstrapToken(t, baseURL, adminToken, role, 1) + priv, pub, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + pID, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + pubBytes, err := crypto.MarshalPublicKey(pub) + if err != nil { + t.Fatal(err) + } + out := bootstrapEnroll(t, baseURL, token, priv, pID, pubBytes, role, labels) + if out.Status != api.EnrollmentStatus_ENROLLMENT_STATUS_APPROVED { + t.Fatalf("enroll %s: status %v (%s)", role, out.Status, out.ErrorMessage) + } + return out.BiscuitToken + } + pepToken := enroll(t, "pep", nil) + dc1Token := enroll(t, api.RoleNode, map[string]string{"site": "dc1"}) + + getMesh := func(t *testing.T, path string, token []byte, out proto.Message) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, baseURL+path, nil) + req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(token)) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %s %s", path, resp.Status, body) + } + if err := proto.Unmarshal(body, out); err != nil { + t.Fatalf("GET %s: decode: %v", path, err) + } + } + + var rules api.PolicyConfigGetResponse + getMesh(t, "/policies", pepToken, &rules) + for _, want := range []string{ + `granted_service_exact("egress", "api.github.com") <- role("pep")`, + `granted_service_exact("egress", "mam.internal.example.com") <- label("site", "dc1")`, + `http_granted_service_exact("egress", "mam.internal.example.com") <- role("sam:role:node")`, + `granted_method("egress", "mam.internal.example.com", ["GET"]) <- role("sam:role:node")`, + } { + if !slices.Contains(rules.DatalogRules, want) { + t.Errorf("datalog_rules lack %q:\n%s", want, strings.Join(rules.DatalogRules, "\n")) + } + } + // The response stays within the contract an older node checks. + if len(rules.ProtoReflect().GetUnknown()) > 0 { + t.Errorf("policy response carries unknown fields") + } + + names := func(resp *api.EgressAssignmentsResponse) []string { + var out []string + for _, d := range resp.Egress { + out = append(out, d.Name) + } + return out + } + var pepEgress, dc1Egress api.EgressAssignmentsResponse + getMesh(t, "/egress", pepToken, &pepEgress) + if got := names(&pepEgress); !slices.Equal(got, []string{"api.github.com"}) { + t.Errorf("pep node assigned %v, want [api.github.com]", got) + } + if pepEgress.Egress[0].Credential != "github-eu" { + t.Errorf("assignment lost its credential name: %v", pepEgress.Egress[0]) + } + getMesh(t, "/egress", dc1Token, &dc1Egress) + if got := names(&dc1Egress); !slices.Equal(got, []string{"mam.internal.example.com"}) { + t.Errorf("dc1 node assigned %v, want [mam.internal.example.com]", got) + } + if dc1Egress.Egress[0].TargetUrl != "http://mam.internal.example.com:8080" { + t.Errorf("assignment lost its target_url: %v", dc1Egress.Egress[0]) + } + + // Without a node credential there is nothing to select on. + resp, err = client.Get(baseURL + "/egress") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("anonymous GET /egress: %s, want 401", resp.Status) + } + + // A selector that matches no enrolled node is valid in form and is + // reported, so a typo does not surface only as 404s at the callers. + roles, bindings, err := store.GetMeshPolicy(ctx) + if err != nil { + t.Fatal(err) + } + unserved, err := srv.unservedEgress(ctx, &api.PolicyConfig{Roles: roles, Bindings: bindings, Egress: []*api.EgressDestination{ + {Name: "api.github.com", ServedBy: []string{"pep"}}, + {Name: "mam.internal.example.com", ServedBy: []string{"site=dc1"}}, + {Name: "typo.example", ServedBy: []string{"site=dc-1"}}, + {Name: "nobody.example", ServedBy: []string{"contractor"}}, + }}) + if err != nil { + t.Fatal(err) + } + var unservedNames []string + for _, d := range unserved { + unservedNames = append(unservedNames, d.Name) + } + if want := []string{"typo.example", "nobody.example"}; !slices.Equal(unservedNames, want) { + t.Errorf("unserved = %v, want %v", unservedNames, want) + } + + // Validation names the mistake. + for _, tc := range []struct{ name, body, want string }{ + {"unknown served_by", `{"roles":[{"name":"pep"}],"egress":[{"name":"x.example","served_by":["ghost"]}]}`, "neither a role"}, + {"credential is a path", `{"roles":[{"name":"pep"}],"egress":[{"name":"x.example","credential":"/etc/x","served_by":["pep"]}]}`, "not a path"}, + {"duplicate destination", `{"roles":[{"name":"pep"}],"egress":[{"name":"x.example","served_by":["pep"]},{"name":"x.example","served_by":["pep"]}]}`, "duplicate egress"}, + {"http narrows a service the role lacks", `{"roles":[{"name":"pep","allowed_services":["mcp://a"],"http":[{"service":"mcp://b","methods":["GET"]}]}]}`, "does not name one of the role's allowed_services"}, + {"http narrows nothing", `{"roles":[{"name":"pep","allowed_services":["mcp://a"],"http":[{"service":"mcp://a"}]}]}`, "narrows nothing"}, + {"a URL where a hostname belongs", `{"roles":[{"name":"pep","allowed_services":["egress://api.github.com/v3"]}]}`, "without a scheme, a port or a path"}, + {"uppercase egress grant", `{"roles":[{"name":"pep","allowed_services":["egress://API.github.com"]}]}`, "lowercase"}, + } { + status, body := postPolicy(t, tc.body) + if status != http.StatusBadRequest || !strings.Contains(body, tc.want) { + t.Errorf("%s: got %d %q, want 400 mentioning %q", tc.name, status, body, tc.want) + } + } +} diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index 661c8921..afbf8ed2 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -235,6 +235,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { handle("/keys", meshSurface(s.HandleKeys)) handle("/routers/lease", s.HandleRouterLease) handle("/policies", meshSurface(s.HandlePolicies)) + handle("/egress", meshSurface(s.HandleEgress)) handle("/enroll", meshSurface(noStore(s.HandleEnroll))) handle("/enroll/status", meshSurface(noStore(s.HandleEnrollStatus))) handle("/refresh", meshSurface(noStore(s.HandleRefresh))) @@ -1190,11 +1191,20 @@ func (s *Server) HandlePolicies(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + egress, err := s.store.GetEgressDestinations(r.Context()) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to load egress destinations: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } policyRules, warnings := api.BuildPolicyRules(roles, bindings) for _, warning := range warnings { logger.Warnf("mesh policy: %s", warning) } + // The nodes that serve a destination are granted it, so the serving + // node authorizes local requests with its own credential. + policyRules = append(policyRules, api.BuildEgressServingRules(egress)...) respData, _ := proto.Marshal(&api.PolicyConfigGetResponse{DatalogRules: api.PolicyRuleTexts(policyRules)}) w.Header().Set("Content-Type", "application/x-protobuf") w.WriteHeader(http.StatusOK) @@ -1233,11 +1243,12 @@ func (s *Server) HandlePolicies(w http.ResponseWriter, r *http.Request) { return } - if err := s.store.SaveMeshPolicy(r.Context(), req.Roles, req.Bindings); err != nil { + if err := s.store.SavePolicyDocument(r.Context(), req.Roles, req.Bindings, req.Egress); err != nil { logger.Errorf("Failed to save policy: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + s.warnUnservedEgress(r.Context(), req) if err := s.getMeshAdapter().PublishEvent(r.Context(), api.MeshEvent_POLICY_UPDATE, "", nil); err != nil { logger.Warnf("Failed to publish POLICY_UPDATE event to mesh: %v", err) @@ -1258,29 +1269,155 @@ func (s *Server) HandlePolicies(w http.ResponseWriter, r *http.Request) { } } +// warnUnservedEgress logs each destination of a just-posted policy that no +// enrolled, admitted node is selected to serve. A selector with a typo is +// valid in form and would otherwise fail silently, as a 404 to every caller. +// A warning and not an error: the node it selects may enroll later. +func (s *Server) warnUnservedEgress(ctx context.Context, policy *api.PolicyConfig) { + unserved, err := s.unservedEgress(ctx, policy) + if err != nil { + logger.Warnf("mesh policy: cannot check egress selectors against enrolled nodes: %v", err) + return + } + for _, d := range unserved { + logger.Warnf("mesh policy: egress destination %s is served by no enrolled node (served_by %v); callers get 404 until one matches", d.GetName(), d.GetServedBy()) + } +} + +// unservedEgress returns the destinations of policy whose served_by selects +// no enrolled, admitted node, with roles resolved as a refresh would. +func (s *Server) unservedEgress(ctx context.Context, policy *api.PolicyConfig) ([]*api.EgressDestination, error) { + if len(policy.GetEgress()) == 0 { + return nil, nil + } + nodes, err := s.store.ListNodes(ctx) + if err != nil { + return nil, err + } + now := time.Now() + var unserved []*api.EgressDestination + for _, d := range policy.GetEgress() { + served := false + for i := range nodes { + node := &nodes[i] + if node.CheckAdmission(now) != nil { + continue + } + roles, err := nodeRoles(node, policy.GetBindings()) + if err != nil { + continue + } + if api.EgressServedBy(d, roles, node.Labels) { + served = true + break + } + } + if !served { + unserved = append(unserved, d) + } + } + return unserved, nil +} + // isAdmittedNodeRequest reports whether the bearer credential is a biscuit of // an enrolled, admitted node. It never falls through to OIDC: running ID token // verification on a biscuit logs a failure and would auto-register whoever's // ID token lands here. func (s *Server) isAdmittedNodeRequest(r *http.Request) bool { + return s.admittedNode(r) != nil +} + +// admittedNode returns the enrolled, admitted node whose biscuit the request +// bears, or nil. +func (s *Server) admittedNode(r *http.Request) *storage.EnrolledNode { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { - return false + return nil } biscuitBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Bearer ")) if err != nil { - return false + return nil } trustedKeys, err := s.store.GetAllValidPublicKeys(r.Context()) if err != nil { - return false + return nil } peerID, err := identity.VerifyAndExtractPeerID(trustedKeys, biscuitBytes, s.config.BiscuitTimeout) if err != nil { - return false + return nil } nodeRecord, err := s.store.GetNode(r.Context(), peerID.String()) - return err == nil && nodeRecord != nil && nodeRecord.CheckAdmission(time.Now()) == nil + if err != nil || nodeRecord == nil || nodeRecord.CheckAdmission(time.Now()) != nil { + return nil + } + return nodeRecord +} + +// HandleEgress HTTP GET `/egress`: the egress destinations the requesting +// node serves, selected by its roles and labels (see EgressDestination). +// Mesh protocol, biscuit-authenticated, binary protobuf. A separate endpoint +// from /policies so a node predating it keeps syncing rules unchanged. +func (s *Server) HandleEgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + nodeRecord := s.admittedNode(r) + if nodeRecord == nil { + http.Error(w, "Unauthorized: node credential required", http.StatusUnauthorized) + return + } + _, bindings, err := s.store.GetMeshPolicy(r.Context()) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to load policy: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + egress, err := s.store.GetEgressDestinations(r.Context()) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to load egress destinations: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + roles, err := nodeRoles(nodeRecord, bindings) + if err != nil { + logger.Errorf("Failed to resolve roles for node %s: %v", nodeRecord.PeerID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + resp := &api.EgressAssignmentsResponse{} + for _, d := range egress { + if api.EgressServedBy(d, roles, nodeRecord.Labels) { + resp.Egress = append(resp.Egress, d) + } + } + respData, err := proto.Marshal(resp) + if err != nil { + http.Error(w, "Failed to serialize response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-protobuf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(respData) +} + +// nodeRoles is the role set a refresh would mint for the node: the enrolled +// role plus the custom roles its identity resolves to, from the bindings. +func nodeRoles(nodeRecord *storage.EnrolledNode, bindings []*api.PolicyBinding) ([]string, error) { + roles := []string{nodeRecord.Role} + if nodeRecord.EnrollmentType != "OIDC" { + return roles, nil + } + var claims jwt.MapClaims + if err := json.Unmarshal([]byte(nodeRecord.ClaimsJSON), &claims); err != nil { + return nil, err + } + for _, r := range resolveRoles(nodeRecord.PeerID, claims, bindings) { + if !strings.HasPrefix(r, "sam:role:") && r != nodeRecord.Role { + roles = append(roles, r) + } + } + return roles, nil } // HandleAdminPolicy HTTP GET `/admin/policy`: the mesh policy as the operator @@ -1299,7 +1436,13 @@ func (s *Server) HandleAdminPolicy(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - writeProtoJSON(w, &api.PolicyConfig{Roles: roles, Bindings: bindings}) + egress, err := s.store.GetEgressDestinations(r.Context()) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to load egress destinations: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + writeProtoJSON(w, &api.PolicyConfig{Roles: roles, Bindings: bindings, Egress: egress}) } // writeProtoJSON answers an operator-plane request with protojson of msg, @@ -2545,7 +2688,11 @@ func (s *Server) HandleUserStatus(w http.ResponseWriter, r *http.Request) { if err != nil && err != storage.ErrNotFound { logger.Errorf("Failed to list policy: %v", err) } - if rendered, err := marshalPolicyJSON(roles, bindings); err == nil { + egress, err := s.store.GetEgressDestinations(ctx) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to list egress destinations: %v", err) + } + if rendered, err := marshalPolicyJSON(roles, bindings, egress); err == nil { resp["policy_json"] = rendered } else { logger.Errorf("Failed to render policy: %v", err) @@ -2906,8 +3053,8 @@ func toStringSlice(val any) []string { // field names. Generated marshalling is the point: a hand-maintained mirror of // PolicyRole silently drops any field it forgets, which is how custom_datalog // went missing from the console for so long. -func marshalPolicyJSON(roles []*api.PolicyRole, bindings []*api.PolicyBinding) (string, error) { - resp := &api.PolicyConfig{Roles: roles, Bindings: bindings} +func marshalPolicyJSON(roles []*api.PolicyRole, bindings []*api.PolicyBinding, egress []*api.EgressDestination) (string, error) { + resp := &api.PolicyConfig{Roles: roles, Bindings: bindings, Egress: egress} marshaler := protojson.MarshalOptions{UseProtoNames: true, Multiline: true, Indent: " "} out, err := marshaler.Marshal(resp) if err != nil { @@ -2949,6 +3096,9 @@ func validatePolicyConfig(req *api.PolicyConfig) error { if err := api.ValidateServiceFormat(svc); err != nil { return fmt.Errorf("invalid allowed_service %q in role %s: %w", svc, r.Name, err) } + if err := api.ValidateEgressServicePattern(svc); err != nil { + return fmt.Errorf("in role %s: %w", r.Name, err) + } } for _, target := range r.AllowedTargets { if err := api.ValidateTargetFormat(target); err != nil { @@ -2965,6 +3115,11 @@ func validatePolicyConfig(req *api.PolicyConfig) error { return fmt.Errorf("in role %s: %w", r.Name, err) } } + for _, g := range r.Http { + if err := api.ValidateHTTPGrant(g, r.AllowedServices); err != nil { + return fmt.Errorf("in role %s: %w", r.Name, err) + } + } for _, dl := range r.CustomDatalog { trimmed := strings.TrimRight(strings.TrimSpace(dl), ";") if trimmed == "" { @@ -2985,6 +3140,9 @@ func validatePolicyConfig(req *api.PolicyConfig) error { factBudget += len(api.BuildTargetDatalogFacts(r.AllowedTargets)) factBudget += len(api.BuildAgentDatalogFacts(r.AllowedAgents)) factBudget += len(r.CustomDatalog) + for _, g := range r.Http { + factBudget += len(api.BuildHTTPGrantFacts(g)) + } } if factBudget > maxIdentityFactBudget { @@ -3023,5 +3181,19 @@ func validatePolicyConfig(req *api.PolicyConfig) error { } } } + + egressNames := make(map[string]bool, len(req.Egress)) + for _, d := range req.Egress { + if d == nil { + continue + } + if err := api.ValidateEgressDestination(d, roleNames); err != nil { + return err + } + if egressNames[d.Name] { + return fmt.Errorf("duplicate egress destination: %s", d.Name) + } + egressNames[d.Name] = true + } return nil } diff --git a/internal/controlplane/server_test.go b/internal/controlplane/server_test.go index 959ea277..250987e8 100644 --- a/internal/controlplane/server_test.go +++ b/internal/controlplane/server_test.go @@ -582,7 +582,7 @@ func TestMarshalPolicyJSONRoundTrip(t *testing.T) { {Role: "ops", Members: []string{"user:root"}}, } - rendered, err := marshalPolicyJSON(roles, bindings) + rendered, err := marshalPolicyJSON(roles, bindings, nil) if err != nil { t.Fatalf("rendering the policy: %v", err) } @@ -675,7 +675,7 @@ func TestPoliciesAcceptConsoleJSON(t *testing.T) { } // What /status hands the console must be postable back unchanged. - rendered, err := marshalPolicyJSON(roles, bindings) + rendered, err := marshalPolicyJSON(roles, bindings, nil) if err != nil { t.Fatalf("rendering the stored policy: %v", err) } diff --git a/internal/controlplane/ui.go b/internal/controlplane/ui.go index 064f5a04..27e152bb 100644 --- a/internal/controlplane/ui.go +++ b/internal/controlplane/ui.go @@ -72,9 +72,13 @@ func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { if err != nil { logger.Errorf("Failed to list policy: %v", err) } + egress, err := s.store.GetEgressDestinations(r.Context()) + if err != nil { + logger.Errorf("Failed to list egress destinations: %v", err) + } var policyJSON string - if rendered, err := marshalPolicyJSON(roles, bindings); err == nil { + if rendered, err := marshalPolicyJSON(roles, bindings, egress); err == nil { policyJSON = rendered } else { logger.Errorf("Failed to render policy: %v", err) diff --git a/internal/storage/mesh_policy_test.go b/internal/storage/mesh_policy_test.go index 23808961..6cc95c65 100644 --- a/internal/storage/mesh_policy_test.go +++ b/internal/storage/mesh_policy_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/google/sam/api" + "google.golang.org/protobuf/proto" ) // TestMeshPolicyRoundTripsEveryRoleField stores a role with every repeated @@ -46,6 +47,7 @@ func TestMeshPolicyRoundTripsEveryRoleField(t *testing.T) { CustomDatalog: []string{`region("emea")`}, AllowedAgents: []string{"*.prod.acme.example"}, AllowedLabels: []string{"region=*"}, + Http: []*api.HTTPGrant{{Service: "mcp://tool", Methods: []string{"GET"}, Paths: []string{"/v1/*"}}}, } if err := store.SaveMeshPolicy(ctx, []*api.PolicyRole{want}, nil); err != nil { @@ -72,8 +74,63 @@ func TestMeshPolicyRoundTripsEveryRoleField(t *testing.T) { } w := wantVal.Field(i).Interface() g := gotVal.Field(i).Interface() + if field.Type.Elem().Implements(reflect.TypeOf((*proto.Message)(nil)).Elem()) { + // Message slices carry internal state DeepEqual would compare. + wm, gm := reflect.ValueOf(w), reflect.ValueOf(g) + if wm.Len() != gm.Len() { + t.Errorf("field %s did not round trip: saved %d entries, loaded %d", field.Name, wm.Len(), gm.Len()) + continue + } + for j := 0; j < wm.Len(); j++ { + if !proto.Equal(wm.Index(j).Interface().(proto.Message), gm.Index(j).Interface().(proto.Message)) { + t.Errorf("field %s[%d] did not round trip: saved %v, loaded %v", field.Name, j, wm.Index(j), gm.Index(j)) + } + } + continue + } if !reflect.DeepEqual(w, g) { t.Errorf("field %s did not round trip: saved %v, loaded %v", field.Name, w, g) } } } + +// TestSavePolicyDocumentIsAtomic: a document is applied whole or not at all. +// The control plane validates before saving, so the failing row here is one +// validation would have refused; what matters is that the roles written in +// the same call are rolled back with it. +func TestSavePolicyDocumentIsAtomic(t *testing.T) { + store, err := NewSQLStore("sqlite", filepath.Join(t.TempDir(), "policy.db")) + if err != nil { + t.Fatalf("NewSQLStore: %v", err) + } + defer func() { _ = store.Close() }() + ctx := context.Background() + + first := []*api.PolicyRole{{Name: "first"}} + egress := []*api.EgressDestination{{Name: "api.github.com", Credential: "gh", ServedBy: []string{"first"}}} + if err := store.SavePolicyDocument(ctx, first, nil, egress); err != nil { + t.Fatalf("SavePolicyDocument: %v", err) + } + got, err := store.GetEgressDestinations(ctx) + if err != nil || len(got) != 1 || !proto.Equal(got[0], egress[0]) { + t.Fatalf("GetEgressDestinations = %v, %v", got, err) + } + + // Two rows with the same name violate the primary key after the roles + // were already replaced inside the transaction. + bad := []*api.EgressDestination{{Name: "dup.example", ServedBy: []string{"second"}}, {Name: "dup.example", ServedBy: []string{"second"}}} + if err := store.SavePolicyDocument(ctx, []*api.PolicyRole{{Name: "second"}}, nil, bad); err == nil { + t.Fatal("SavePolicyDocument accepted a duplicate destination") + } + roles, _, err := store.GetMeshPolicy(ctx) + if err != nil { + t.Fatal(err) + } + if len(roles) != 1 || roles[0].Name != "first" { + t.Errorf("roles after a failed document = %v, want the previous document's", roles) + } + got, err = store.GetEgressDestinations(ctx) + if err != nil || len(got) != 1 || got[0].Name != "api.github.com" { + t.Errorf("egress after a failed document = %v, %v, want the previous document's", got, err) + } +} diff --git a/internal/storage/sql_store.go b/internal/storage/sql_store.go index 6c3f01fa..836f0efd 100644 --- a/internal/storage/sql_store.go +++ b/internal/storage/sql_store.go @@ -27,6 +27,7 @@ import ( "github.com/google/sam/api" log "github.com/ipfs/go-log/v2" + "google.golang.org/protobuf/encoding/protojson" // Register PG and SQLite drivers _ "github.com/jackc/pgx/v5/stdlib" @@ -451,6 +452,28 @@ var migrations = []migration{ `ALTER TABLE users ADD COLUMN issuer TEXT DEFAULT '' NOT NULL`, }, }, + { + // Egress destinations are part of the policy document (PolicyConfig.egress) + // but are not role-scoped, so they get their own table. served_by is a + // JSON array of role names and key=value labels. + version: 12, + postgres: []string{ + `CREATE TABLE IF NOT EXISTS egress_destinations ( + name VARCHAR(253) PRIMARY KEY, + target_url TEXT NOT NULL, + credential VARCHAR(64) NOT NULL, + served_by TEXT NOT NULL + )`, + }, + sqlite: []string{ + `CREATE TABLE IF NOT EXISTS egress_destinations ( + name TEXT PRIMARY KEY, + target_url TEXT NOT NULL, + credential TEXT NOT NULL, + served_by TEXT NOT NULL + )`, + }, + }, } func (s *SQLStore) initSchema() error { @@ -992,12 +1015,36 @@ func (s *SQLStore) GetActiveRouters(ctx context.Context) ([]RouterLease, error) // SaveMeshPolicy replaces the entire mesh policy with the provided roles and bindings. func (s *SQLStore) SaveMeshPolicy(ctx context.Context, roles []*api.PolicyRole, bindings []*api.PolicyBinding) error { + return s.inTx(ctx, func(tx *sql.Tx) error { + return s.saveMeshPolicyTx(ctx, tx, roles, bindings) + }) +} + +// SavePolicyDocument replaces roles, bindings and egress destinations in one +// transaction, so a POST /policies is applied whole or not at all. +func (s *SQLStore) SavePolicyDocument(ctx context.Context, roles []*api.PolicyRole, bindings []*api.PolicyBinding, egress []*api.EgressDestination) error { + return s.inTx(ctx, func(tx *sql.Tx) error { + if err := s.saveMeshPolicyTx(ctx, tx, roles, bindings); err != nil { + return err + } + return s.saveEgressDestinationsTx(ctx, tx, egress) + }) +} + +// inTx runs fn in a transaction and commits when it returns nil. +func (s *SQLStore) inTx(ctx context.Context, fn func(tx *sql.Tx) error) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} +func (s *SQLStore) saveMeshPolicyTx(ctx context.Context, tx *sql.Tx, roles []*api.PolicyRole, bindings []*api.PolicyBinding) error { // For simplicity in replacing policy, we clear all and insert new. if _, err := tx.ExecContext(ctx, "DELETE FROM role_permissions"); err != nil { return err @@ -1041,6 +1088,20 @@ func (s *SQLStore) SaveMeshPolicy(ctx context.Context, roles []*api.PolicyRole, return err } } + // One row per narrowed entry, as protojson: the shape is the proto's + // and a new HTTPGrant field needs no schema change here. + for _, g := range r.Http { + if g == nil { + continue + } + encoded, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(g) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'http', ?)"), r.Name, string(encoded)); err != nil { + return err + } + } } for _, b := range bindings { @@ -1054,7 +1115,7 @@ func (s *SQLStore) SaveMeshPolicy(ctx context.Context, roles []*api.PolicyRole, } } - return tx.Commit() + return nil } // GetMeshPolicy retrieves the entire mesh policy as structured data. @@ -1104,6 +1165,12 @@ func (s *SQLStore) GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api r.AllowedAgents = append(r.AllowedAgents, resValue) case "label": r.AllowedLabels = append(r.AllowedLabels, resValue) + case "http": + g := &api.HTTPGrant{} + if err := protojson.Unmarshal([]byte(resValue), g); err != nil { + return nil, nil, fmt.Errorf("role %s: stored http grant does not parse: %w", roleName, err) + } + r.Http = append(r.Http, g) } } } @@ -1142,6 +1209,57 @@ func (s *SQLStore) GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api return roles, bindings, nil } +// SaveEgressDestinations replaces the egress section of the mesh policy. +func (s *SQLStore) SaveEgressDestinations(ctx context.Context, egress []*api.EgressDestination) error { + return s.inTx(ctx, func(tx *sql.Tx) error { + return s.saveEgressDestinationsTx(ctx, tx, egress) + }) +} + +func (s *SQLStore) saveEgressDestinationsTx(ctx context.Context, tx *sql.Tx, egress []*api.EgressDestination) error { + if _, err := tx.ExecContext(ctx, "DELETE FROM egress_destinations"); err != nil { + return err + } + for _, d := range egress { + if d == nil { + continue + } + servedBy, err := json.Marshal(d.GetServedBy()) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO egress_destinations (name, target_url, credential, served_by) VALUES (?, ?, ?, ?)"), + d.GetName(), d.GetTargetUrl(), d.GetCredential(), string(servedBy)); err != nil { + return err + } + } + return nil +} + +// GetEgressDestinations loads the egress section of the mesh policy, in name +// order so the rendered document and rules are stable. +func (s *SQLStore) GetEgressDestinations(ctx context.Context) ([]*api.EgressDestination, error) { + rows, err := s.db.QueryContext(ctx, s.rebind("SELECT name, target_url, credential, served_by FROM egress_destinations ORDER BY name")) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var egress []*api.EgressDestination + for rows.Next() { + var name, targetURL, credential, servedByJSON string + if err := rows.Scan(&name, &targetURL, &credential, &servedByJSON); err != nil { + return nil, err + } + var servedBy []string + if err := json.Unmarshal([]byte(servedByJSON), &servedBy); err != nil { + return nil, fmt.Errorf("egress %s: stored served_by does not parse: %w", name, err) + } + egress = append(egress, &api.EgressDestination{Name: name, TargetUrl: targetURL, Credential: credential, ServedBy: servedBy}) + } + return egress, rows.Err() +} + // SaveBootstrapToken persists a new bootstrap token. func (s *SQLStore) SaveBootstrapToken(ctx context.Context, token *BootstrapToken) error { var query string diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 48103a99..c3151b82 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -244,6 +244,17 @@ type Store interface { // GetMeshPolicy loads the mesh configurations. GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api.PolicyBinding, error) + // SaveEgressDestinations replaces the egress section of the mesh policy + // (PolicyConfig.egress). + SaveEgressDestinations(ctx context.Context, egress []*api.EgressDestination) error + + // GetEgressDestinations loads the egress section of the mesh policy. + GetEgressDestinations(ctx context.Context) ([]*api.EgressDestination, error) + + // SavePolicyDocument replaces roles, bindings and egress destinations in + // one transaction: a policy post is applied whole or not at all. + SavePolicyDocument(ctx context.Context, roles []*api.PolicyRole, bindings []*api.PolicyBinding, egress []*api.EgressDestination) error + // SaveBootstrapToken persists a new bootstrap token. SaveBootstrapToken(ctx context.Context, token *BootstrapToken) error From 4ab7f6f545e838fcccaae922fa0a842ae1eb4fed Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 20:03:19 +0000 Subject: [PATCH 3/6] node: serve assigned egress destinations and decide on method and path Authorize injects method() and path() when the node handles a request as HTTP, and host() and port() on a request for an egress destination, all taken from the wire; the HTTP narrowing rules join the baseline. The ingress computes the upstream path before verification, so path() is what the backend sees and never the routing prefix. The node pulls its egress assignments with the rest of the control plane state and reconciles the registry: an EgressService is the HTTP origin for one destination, forwards to its target_url, and presents the credential named by the policy, read per request from --secrets-dir so a rotation by the platform applies at once. The caller's Authorization, X-Sam-* and X-Forwarded-* headers do not reach the destination. A destination whose credential is missing is refused and logged; the others are served. type: egress in sam-node.yaml is refused: a destination is the control plane's to assign. /egress/{destination}/{path} on the local API lets an application on the host reach a destination the node serves. The node evaluates its own credential with the request facts and the agent the client names; the target check is satisfied because a node is always allowed to reach itself. Another member reaches the same destination through /sam/{peer}/egress/{destination}/{path}, on its own credential. sam-box: an address the guest dialled without resolving a name is allowed only by an exact egress entry; a wildcard names a zone and covers no address. --- cmd/sam-node/main.go | 5 + internal/node/config.go | 5 + internal/node/controlplane_sync.go | 3 + internal/node/discovery_source.go | 3 + internal/node/egress.go | 344 +++++++++++++++++++++++++++ internal/node/egress_metrics.go | 60 +++++ internal/node/egress_route_test.go | 213 +++++++++++++++++ internal/node/egress_test.go | 366 +++++++++++++++++++++++++++++ internal/node/middleware.go | 91 ++++++- internal/node/node.go | 23 +- internal/node/options.go | 7 + internal/node/service.go | 2 + internal/node/sidecar.go | 9 + internal/sambox/route.go | 26 +- internal/sambox/route_test.go | 29 +++ 15 files changed, 1174 insertions(+), 12 deletions(-) create mode 100644 internal/node/egress.go create mode 100644 internal/node/egress_metrics.go create mode 100644 internal/node/egress_route_test.go create mode 100644 internal/node/egress_test.go diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index aa4adc72..f7edd73b 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -103,6 +103,7 @@ var ( dhtLookupLimitFlag int discoveryConcurrencyFlag int backendProbeTimeoutFlag time.Duration + secretsDirFlag string controlPlaneSyncIntervalFlag time.Duration ) @@ -496,6 +497,7 @@ func main() { DHTLookupLimit: dhtLookupLimitFlag, DiscoveryConcurrency: discoveryConcurrencyFlag, BackendProbeTimeout: backendProbeTimeoutFlag, + SecretsDir: secretsDirFlag, }) if err != nil { logger.Fatalf("Failed to initialize mesh node: %v", err) @@ -566,6 +568,7 @@ func main() { DHTLookupLimit: dhtLookupLimitFlag, DiscoveryConcurrency: discoveryConcurrencyFlag, BackendProbeTimeout: backendProbeTimeoutFlag, + SecretsDir: secretsDirFlag, }) if err != nil { enrollCancel() @@ -633,6 +636,7 @@ func main() { RequiredRole: api.RoleNode, ControlPlaneSyncInterval: controlPlaneSyncIntervalFlag, BackendProbeTimeout: backendProbeTimeoutFlag, + SecretsDir: secretsDirFlag, }) if err != nil { logger.Fatalf("Failed to initialize node after enrollment: %v", err) @@ -899,6 +903,7 @@ func main() { runCmd.Flags().IntVar(&discoveryConcurrencyFlag, "discovery-concurrency", 0, "Max concurrent catalog fetches during discovery (0 uses default 10)") runCmd.Flags().DurationVar(&controlPlaneSyncIntervalFlag, "control-plane-sync-interval", node.DefaultControlPlaneSyncInterval, "How often signing keys, bans, router addresses and mesh policy are pulled from the control plane. Keep it well below the control plane's --key-grace-period; raise it on large meshes.") runCmd.Flags().DurationVar(&backendProbeTimeoutFlag, "backend-probe-timeout", 0, "Timeout for probing a command-spawned service backend before advertising it (0 uses default 2s); raise this for backends with slower cold-start times") + runCmd.Flags().StringVar(&secretsDirFlag, "secrets-dir", node.DefaultSecretsDir, "Directory holding the credentials named by the control plane's egress destinations, one file per credential name") rootCmd.PersistentFlags().StringVar(&controlPlaneAddr, "control-plane", "", "Control plane URL") rootCmd.PersistentFlags().BoolVar(&insecureControlPlaneFlag, "insecure-control-plane", false, "Accept a plaintext http:// control plane URL to a non-loopback host (whoever answers it becomes this node's trust root; only for networks you already trust)") rootCmd.PersistentFlags().StringVar(&configFile, "config", node.DefaultConfigFile, "Path to sam-node.yaml configuration file") diff --git a/internal/node/config.go b/internal/node/config.go index 85f79d7c..b86f1932 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -108,6 +108,11 @@ func CompleteNodeConfig(config api.NodeConfig) (*NodeConfigComplete, error) { if err := api.ValidateServiceFormat(svc.Type + "://" + svc.Name); err != nil { return nil, fmt.Errorf("invalid service config at index %d: %w", i, err) } + // Nodes serve what the control plane assigns them; a destination + // declared here would give one node a policy of its own. + if t, err := api.ParseServiceType(svc.Type); err == nil && t == api.ServiceType_SERVICE_TYPE_EGRESS { + return nil, fmt.Errorf("service %q: egress destinations are assigned by the control plane (PolicyConfig.egress), not declared in the node config", svc.Name) + } if svc.TargetAuthPath != "" && svc.TargetURL == "" { return nil, fmt.Errorf("service %q: target_auth_path needs a target_url", svc.Name) } diff --git a/internal/node/controlplane_sync.go b/internal/node/controlplane_sync.go index bf02c01f..3d279450 100644 --- a/internal/node/controlplane_sync.go +++ b/internal/node/controlplane_sync.go @@ -61,6 +61,9 @@ func (n *SamNode) SyncControlPlane(ctx context.Context) error { if err := n.syncMeshPolicy(ctx); err != nil { errs = append(errs, fmt.Errorf("policy: %w", err)) } + if err := n.syncEgressAssignments(ctx, controlPlaneURL); err != nil { + errs = append(errs, fmt.Errorf("egress: %w", err)) + } return errors.Join(errs...) } diff --git a/internal/node/discovery_source.go b/internal/node/discovery_source.go index 8b860cbe..b6cb4441 100644 --- a/internal/node/discovery_source.go +++ b/internal/node/discovery_source.go @@ -85,6 +85,9 @@ func serviceKeys(ctx context.Context, svc Service, t api.ServiceType) ([]string, if lister, ok := svc.(toolLister); ok { return lister.Tools(ctx) } + case api.ServiceType_SERVICE_TYPE_EGRESS: + // The destination name is the only key: callers look a hostname up. + return []string{svc.Info().GetName()}, nil } return nil, nil } diff --git a/internal/node/egress.go b/internal/node/egress.go new file mode 100644 index 00000000..6dedc4c0 --- /dev/null +++ b/internal/node/egress.go @@ -0,0 +1,344 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package node + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/google/sam/api" + cpclient "github.com/google/sam/internal/controlplane/client" + "google.golang.org/protobuf/proto" +) + +// DefaultSecretsDir is where a node looks for the credentials the control +// plane names in egress destinations when --secrets-dir is not given. +const DefaultSecretsDir = "/etc/sam/secrets" + +// Proxy-Status error types (RFC 9209) the node answers with when it, and not +// the destination, refuses a request. A client can then tell a policy denial +// from a 403 the destination sent. +const ( + proxyStatusDenied = "http_request_denied" + proxyStatusDestinationNotFound = "destination_not_found" + proxyStatusConfigurationError = "proxy_configuration_error" +) + +// refuse answers a request the node refuses itself, naming the reason in +// Proxy-Status so the client can tell it from the destination's own answer. +func refuse(w http.ResponseWriter, status int, text, errorType string) { + w.Header().Set("Proxy-Status", "sam-node; error="+errorType) + http.Error(w, text, status) +} + +// EgressService serves egress://: this node is the HTTP origin for one +// destination outside the mesh. The destination, where to forward, and which +// credential to present are the control plane's decision (an +// EgressDestination that selected this node); the node holds no policy of +// its own about it. Every request reaches the handler only after Authorize +// ran with the caller's credential and the method, path, host and port facts. +type EgressService struct { + destination *api.EgressDestination + info *api.ServiceInfo + target *url.URL + secretsDir string + handler http.Handler +} + +// newEgressService builds the service for one assignment. The target URL was +// validated by the control plane; it is parsed again here because this node +// dials it. +func newEgressService(d *api.EgressDestination, secretsDir string) (*EgressService, error) { + if err := api.ValidateEgressName(d.GetName()); err != nil { + return nil, err + } + target, err := url.Parse(api.EgressTargetURL(d)) + if err != nil || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") { + return nil, fmt.Errorf("egress %s: invalid target_url", d.GetName()) + } + if target.User != nil { + return nil, fmt.Errorf("egress %s: target_url must not carry a credential", d.GetName()) + } + if cred := d.GetCredential(); cred != "" && (filepath.Base(cred) != cred || cred == "." || cred == "..") { + return nil, fmt.Errorf("egress %s: credential %q must be a file name", d.GetName(), cred) + } + return &EgressService{ + destination: proto.Clone(d).(*api.EgressDestination), + info: &api.ServiceInfo{ + Type: api.ServiceType_SERVICE_TYPE_EGRESS, + Name: d.GetName(), + Description: "egress to " + target.Scheme + "://" + target.Host, + }, + target: target, + secretsDir: secretsDir, + }, nil +} + +func (s *EgressService) Info() *api.ServiceInfo { return s.info } +func (s *EgressService) Handler() http.Handler { return s.handler } +func (s *EgressService) Teardown() error { return nil } + +// Init builds the reverse proxy and confirms the named credential is +// readable, so a destination whose credential the platform did not deliver is +// refused here, visibly, instead of answering 502 to every request. +func (s *EgressService) Init(ctx context.Context) error { + if s.destination.GetCredential() != "" { + if _, err := s.credential(); err != nil { + return err + } + } + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(s.target) + pr.Out.Host = s.target.Host + // What the caller sent authenticated it to the node, and what the + // node knows about the caller is for policy; none of it is for the + // destination, which sees the node's own credential only. + pr.Out.Header.Del("Authorization") + pr.Out.Header.Del("Cookie") + for name := range pr.Out.Header { + if strings.HasPrefix(name, "X-Sam-") || strings.HasPrefix(name, "X-Forwarded-") || name == api.HeaderPeerID { + pr.Out.Header.Del(name) + } + } + if auth, ok := pr.In.Context().Value(egressAuthKey{}).(string); ok && auth != "" { + pr.Out.Header.Set("Authorization", auth) + } + }, + } + s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := "" + if s.destination.GetCredential() != "" { + // Read per request, so a rotation by the platform applies at once. + var err error + if auth, err = s.credential(); err != nil { + logger.Errorf("[Egress] %s: %v", s.info.Name, err) + recordEgressDecision(s.info.Name, egressOutcomeCredentialUnavailable) + refuse(w, http.StatusBadGateway, "egress credential unavailable", proxyStatusConfigurationError) + return + } + } + proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), egressAuthKey{}, auth))) + }) + return nil +} + +type egressAuthKey struct{} + +// credential reads the named file under the secrets directory and renders it +// as an Authorization value: "TOKEN" as Bearer, "user:pass" as Basic, the +// forms target_auth_path accepts. +func (s *EgressService) credential() (string, error) { + name := s.destination.GetCredential() + data, err := os.ReadFile(filepath.Join(s.secretsDir, name)) + if err != nil { + return "", fmt.Errorf("credential %q: %w (put the file in %s)", name, errors.Unwrap(err), s.secretsDir) + } + cred := strings.TrimSpace(string(data)) + if cred == "" { + return "", fmt.Errorf("credential %q is empty", name) + } + if user, pass, ok := strings.Cut(cred, ":"); ok { + return authorizationFor(user, pass), nil + } + return authorizationFor("", cred), nil +} + +// port is the destination port: explicit in the target URL, or the scheme's. +func (s *EgressService) port() int { + if p := s.target.Port(); p != "" { + if n, err := strconv.Atoi(p); err == nil { + return n + } + } + if s.target.Scheme == "http" { + return 80 + } + return 443 +} + +// sameAssignment reports whether the service already serves d as written. +func (s *EgressService) sameAssignment(d *api.EgressDestination) bool { + return s.destination.GetName() == d.GetName() && + api.EgressTargetURL(s.destination) == api.EgressTargetURL(d) && + s.destination.GetCredential() == d.GetCredential() +} + +// egressFactsFor is the host and port an egress request will be sent to, +// for the authorizer; nil for a service of another type. +func egressFactsFor(svc Service) *EgressFacts { + es, ok := svc.(*EgressService) + if !ok { + return nil + } + return &EgressFacts{Host: es.info.Name, Port: es.port()} +} + +// syncEgressAssignments makes the registry's egress services match what the +// control plane assigned to this node: new and changed destinations are +// registered, withdrawn ones unregistered. One assignment that cannot be +// served (a credential the platform did not deliver) is reported and the +// rest are still applied. +func (n *SamNode) syncEgressAssignments(ctx context.Context, controlPlaneURL string) error { + token := n.GetIdentity() + if len(token) == 0 { + return errors.New("node has no identity token to fetch egress assignments") + } + resp, err := controlPlaneClient(controlPlaneURL).FetchEgress(ctx, token) + if errors.Is(err, cpclient.ErrNotFound) { + // A control plane predating egress destinations assigns none; the + // node keeps whatever it serves and does not report an error. + logger.Debugf("[Egress] control plane %s has no /egress endpoint; no destinations assigned", controlPlaneURL) + return nil + } + if err != nil { + return err + } + return n.applyEgressAssignments(ctx, resp.GetEgress()) +} + +func (n *SamNode) applyEgressAssignments(ctx context.Context, assigned []*api.EgressDestination) error { + if n.services == nil { + // Before Start there is no registry to reconcile against; Start + // applies what arrived last. + n.pendingEgressMu.Lock() + n.pendingEgress = assigned + n.pendingEgressMu.Unlock() + return nil + } + var errs []error + var registered, withdrawn, unchanged int + wanted := make(map[string]bool, len(assigned)) + for _, d := range assigned { + if d == nil { + continue + } + wanted[d.GetName()] = true + if existing, ok := n.services.GetTyped(api.ServiceType_SERVICE_TYPE_EGRESS, d.GetName()); ok { + if es, ok := existing.(*EgressService); ok && es.sameAssignment(d) { + unchanged++ + continue + } + } + svc, err := newEgressService(d, n.config.SecretsDir) + if err != nil { + errs = append(errs, err) + continue + } + if err := n.services.Register(ctx, svc); err != nil { + errs = append(errs, fmt.Errorf("egress %s: %w", d.GetName(), err)) + continue + } + registered++ + logger.Infof("[Egress] Serving egress://%s -> %s (assigned by the control plane)", d.GetName(), api.EgressTargetURL(d)) + } + for _, info := range n.services.List(api.ServiceType_SERVICE_TYPE_EGRESS) { + if !wanted[info.GetName()] { + if err := n.services.Unregister(ctx, info.GetName()); err != nil { + errs = append(errs, err) + } else { + withdrawn++ + logger.Infof("[Egress] Withdrawn egress://%s (no longer assigned)", info.GetName()) + } + } + } + // One line per sync that changed something or failed, so "why is my + // destination not served" has an answer in the log; a quiet sync is debug. + summary := fmt.Sprintf("[Egress] Assignments: %d assigned, %d registered, %d unchanged, %d withdrawn, %d refused", len(assigned), registered, unchanged, withdrawn, len(errs)) + if registered+withdrawn+len(errs) > 0 { + logger.Infof("%s", summary) + } else { + logger.Debugf("%s", summary) + } + recordEgressAssignment("registered", registered) + recordEgressAssignment("withdrawn", withdrawn) + recordEgressAssignment("refused", len(errs)) + return errors.Join(errs...) +} + +// applyPendingEgress registers the assignments that arrived before the +// registry existed. Called by Start once it does. +func (n *SamNode) applyPendingEgress(ctx context.Context) { + n.pendingEgressMu.Lock() + pending := n.pendingEgress + n.pendingEgress = nil + n.pendingEgressMu.Unlock() + if len(pending) == 0 { + return + } + if err := n.applyEgressAssignments(ctx, pending); err != nil { + logger.Warnf("[Egress] applying assignments received before start: %v", err) + } +} + +// handleLocalEgress serves /egress/{host}/{path} on the local API: a client +// of this node asks for a destination this node serves. The caller is this +// node, so its own credential is evaluated, with the request's method and +// path and the destination's host and port, and with the agent the client +// names, as on the mesh datapath. The client's Authorization was for the +// node and does not travel further. +func handleLocalEgress(node *SamNode, w http.ResponseWriter, r *http.Request) { + if hasDotSegment(r.URL.Path) { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + host, upstreamPath, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/egress/"), "/") + host = api.NormalizeMeshHost(host) + if host == "" { + http.Error(w, "Usage: /egress//", http.StatusBadRequest) + return + } + svc, ok := node.services.GetTyped(api.ServiceType_SERVICE_TYPE_EGRESS, host) + if !ok || svc.Handler() == nil { + recordEgressDecision(host, egressOutcomeNotAssigned) + refuse(w, http.StatusNotFound, fmt.Sprintf("no egress destination %q is assigned to this node", host), proxyStatusDestinationNotFound) + return + } + identity := node.GetIdentity() + if len(identity) == 0 { + http.Error(w, "node has no credential yet", http.StatusServiceUnavailable) + return + } + target := api.EgressServicePrefix + host + reqCtx := RequestContext{ + PeerID: node.Host.ID(), + Protocol: "local-api", + Target: target, + Agent: agentClaim(r.Header.Get(api.HeaderSamAgent)), + HTTP: &HTTPRequestFacts{Method: r.Method, Path: "/" + upstreamPath}, + Egress: egressFactsFor(svc), + Local: true, + } + if err := node.VerifyBiscuitToken(identity, reqCtx); err != nil { + recordEgressDecision(host, egressOutcomeDeny) + refuse(w, http.StatusForbidden, "Authorization failed", proxyStatusDenied) + return + } + recordEgressDecision(host, egressOutcomeAllow) + r.Header.Del(api.HeaderSamBiscuit) + r.Header.Del(api.HeaderSamAgent) + r.Header.Set(api.HeaderPeerID, node.Host.ID().String()) + r.URL.Path = "/" + upstreamPath + r.URL.RawPath = "" + svc.Handler().ServeHTTP(w, r) +} diff --git a/internal/node/egress_metrics.go b/internal/node/egress_metrics.go new file mode 100644 index 00000000..4a3ecb70 --- /dev/null +++ b/internal/node/egress_metrics.go @@ -0,0 +1,60 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package node + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// What an operator running a PEP needs to see: which destinations this node +// was told to serve and serves, how each request was decided, and whether +// the credential the platform was supposed to deliver is there. Decisions +// are labelled by destination and outcome and not by caller, so the series +// count stays bounded by the policy document and not by the fleet. +var ( + egressDecisionsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "sam_node_egress_decisions_total", + Help: "Requests for egress destinations this node serves, by destination and outcome (allow, deny, not_assigned, credential_unavailable)", + }, + []string{"destination", "outcome"}, + ) + + egressAssignmentsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "sam_node_egress_assignments_total", + Help: "Egress assignments applied from the control plane, by outcome (registered, withdrawn, refused)", + }, + []string{"outcome"}, + ) +) + +const ( + egressOutcomeAllow = "allow" + egressOutcomeDeny = "deny" + egressOutcomeNotAssigned = "not_assigned" + egressOutcomeCredentialUnavailable = "credential_unavailable" +) + +func recordEgressDecision(destination, outcome string) { + egressDecisionsTotal.WithLabelValues(destination, outcome).Inc() +} + +func recordEgressAssignment(outcome string, count int) { + if count > 0 { + egressAssignmentsTotal.WithLabelValues(outcome).Add(float64(count)) + } +} diff --git a/internal/node/egress_route_test.go b/internal/node/egress_route_test.go new file mode 100644 index 00000000..dea84e1d --- /dev/null +++ b/internal/node/egress_route_test.go @@ -0,0 +1,213 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package node + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/google/sam/api" +) + +// TestLocalEgressRoute is the life of a request from a local client through +// /egress/{host}/{path}: the node's own credential decides, with the +// request's method and path and the assignment's host and port; a narrowed +// grant, the agent the client names and local attenuation all apply; the +// destination sees the node's credential and none of the client's headers. +func TestLocalEgressRoute(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + node, cleanup := startBareNode(t, ctx) + defer cleanup() + + var seen []*http.Request + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Clone(context.Background())) + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + // The node's credential, as the control plane would mint it for a role + // selected to serve api.github.com: the serving grant narrowed to GET + // under /repos/acme/, and an agent namespace it may speak for. + narrowed := api.BuildHTTPGrantFacts(&api.HTTPGrant{Service: "egress://api.github.com", Methods: []string{"GET"}, Paths: []string{"/repos/acme/*"}}) + facts := append(narrowed, + biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactGrantedAgentSuffix, IDs: []biscuit.Term{biscuit.String(".acme.example")}}}, + biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String("egress"), biscuit.String("open.example")}}}, + ) + token, pub := mintFor(t, node.Host.ID(), facts...) + node.SetIdentityCache(token) + node.trustedKeys = []TrustedKey{{Key: pub, ReceivedAt: time.Now()}} + + secrets := t.TempDir() + if err := os.WriteFile(filepath.Join(secrets, "github-eu"), []byte("ghp_secret"), 0o600); err != nil { + t.Fatal(err) + } + node.config.SecretsDir = secrets + cfg, err := CompleteNodeConfig(api.NodeConfig{Attenuation: api.Attenuation{Policies: []string{ + `deny if path($p), $p.starts_with("/repos/acme/vault");`, + }}}) + if err != nil { + t.Fatal(err) + } + node.nodeConfig = cfg + if err := node.applyEgressAssignments(ctx, []*api.EgressDestination{ + {Name: "api.github.com", TargetUrl: upstream.URL, Credential: "github-eu"}, + {Name: "open.example", TargetUrl: upstream.URL}, + }); err != nil { + t.Fatal(err) + } + + socketPath := filepath.Join(t.TempDir(), "node.sock") + srv, err := StartSidecarServer(node, "127.0.0.1:0", socketPath, "api-token", "", "", "") + if err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Close() }() + waitForSocket(t, socketPath) + base := "http://" + node.BoundHTTPAddr + + do := func(t *testing.T, method, path string, headers map[string]string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, base+path, nil) + if err != nil { + t.Fatal(err) + } + // The app sends the node's API token as its bearer, as it would send + // a provider key; the destination must never see it. + req.Header.Set("Authorization", "Bearer api-token") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + return resp + } + + // The node's own refusals name themselves, so an app can tell a policy + // denial from a 403 the destination sent. + proxyStatus := map[int]string{ + http.StatusForbidden: "sam-node; error=http_request_denied", + http.StatusNotFound: "sam-node; error=destination_not_found", + } + + tests := []struct { + name string + method string + path string + headers map[string]string + want int + }{ + {"allowed method under the granted prefix", "GET", "/egress/api.github.com/repos/acme/dubbing/pulls?state=open", nil, http.StatusNoContent}, + {"method outside the narrowed grant", "POST", "/egress/api.github.com/repos/acme/dubbing/pulls", nil, http.StatusForbidden}, + {"path outside the narrowed grant", "GET", "/egress/api.github.com/user", nil, http.StatusForbidden}, + {"local attenuation on path", "GET", "/egress/api.github.com/repos/acme/vault/keys", nil, http.StatusForbidden}, + {"an agent inside the granted namespace", "GET", "/egress/api.github.com/repos/acme/x", map[string]string{api.HeaderSamAgent: "reviewer.acme.example"}, http.StatusNoContent}, + {"an agent outside it", "GET", "/egress/api.github.com/repos/acme/x", map[string]string{api.HeaderSamAgent: "intruder.evil.example"}, http.StatusForbidden}, + {"a destination with a plain grant takes any method", "DELETE", "/egress/open.example/anything", nil, http.StatusNoContent}, + {"a destination not assigned to this node", "GET", "/egress/other.example/x", nil, http.StatusNotFound}, + {"a mesh name is not an egress destination", "GET", "/egress/tools.mcp.sam.alt/x", nil, http.StatusNotFound}, + } + decisions := func(destination, outcome string) float64 { + return counterValue(t, egressDecisionsTotal.WithLabelValues(destination, outcome)) + } + before := map[[2]string]float64{} + for _, k := range [][2]string{{"api.github.com", egressOutcomeAllow}, {"api.github.com", egressOutcomeDeny}, {"open.example", egressOutcomeAllow}, {"other.example", egressOutcomeNotAssigned}} { + before[k] = decisions(k[0], k[1]) + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := do(t, tc.method, tc.path, tc.headers) + if resp.StatusCode != tc.want { + t.Errorf("%s %s: status %d, want %d", tc.method, tc.path, resp.StatusCode, tc.want) + } + if want, ok := proxyStatus[tc.want]; ok && resp.Header.Get("Proxy-Status") != want { + t.Errorf("%s %s: Proxy-Status %q, want %q", tc.method, tc.path, resp.Header.Get("Proxy-Status"), want) + } + if tc.want == http.StatusNoContent && resp.Header.Get("Proxy-Status") != "" { + t.Errorf("%s %s: an allowed request carries Proxy-Status %q", tc.method, tc.path, resp.Header.Get("Proxy-Status")) + } + }) + } + + // Every decision above is one increment of the decisions counter, by + // destination and outcome, so an operator can alert on denials and on + // requests for destinations nobody assigned. + for k, want := range map[[2]string]float64{ + {"api.github.com", egressOutcomeAllow}: 2, + {"api.github.com", egressOutcomeDeny}: 4, + {"open.example", egressOutcomeAllow}: 1, + {"other.example", egressOutcomeNotAssigned}: 1, + } { + if got := decisions(k[0], k[1]) - before[k]; got != want { + t.Errorf("decisions{destination=%q,outcome=%q} moved by %v, want %v", k[0], k[1], got, want) + } + } + + // Without the API token the gate refuses before anything is evaluated. + req, _ := http.NewRequest(http.MethodGet, base+"/egress/api.github.com/repos/acme/x", nil) + resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("untokened request: %d, want 401", resp.StatusCode) + } + + // A dot segment never reaches the destination, however the mux and the + // handler split the work of refusing it. + if resp := do(t, http.MethodGet, "/egress/api.github.com/repos/acme/../../user", nil); resp.StatusCode == http.StatusNoContent { + t.Error("a traversal was forwarded") + } + + // What the destination saw for the allowed requests. + if len(seen) == 0 { + t.Fatal("upstream saw no request") + } + for _, r := range seen { + if strings.Contains(r.URL.Path, "..") || r.URL.Path == "/user" { + t.Errorf("upstream saw a traversal: %s", r.URL.Path) + } + } + first := seen[0] + if first.URL.RequestURI() != "/repos/acme/dubbing/pulls?state=open" { + t.Errorf("upstream URI = %q", first.URL.RequestURI()) + } + if first.Header.Get("Authorization") != "Bearer ghp_secret" { + t.Errorf("upstream Authorization = %q, want the node's credential", first.Header.Get("Authorization")) + } + for _, r := range seen { + for name := range r.Header { + if strings.HasPrefix(name, "X-Sam-") || strings.HasPrefix(name, "X-Forwarded-") || name == api.HeaderPeerID { + t.Errorf("upstream saw %s", name) + } + } + if strings.Contains(r.Header.Get("Authorization"), "api-token") { + t.Error("the client's API token reached the destination") + } + } +} diff --git a/internal/node/egress_test.go b/internal/node/egress_test.go new file mode 100644 index 00000000..b46a1975 --- /dev/null +++ b/internal/node/egress_test.go @@ -0,0 +1,366 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package node + +import ( + "context" + "crypto/ed25519" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/google/sam/api" + "github.com/libp2p/go-libp2p/core/peer" +) + +// mintFor builds a token bound to peerID carrying facts, as the control plane +// would, and returns it with the verifying key. +func mintFor(t *testing.T, peerID peer.ID, facts ...biscuit.Fact) ([]byte, ed25519.PublicKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + builder := biscuit.NewBuilder(priv) + base := []biscuit.Fact{ + {Predicate: biscuit.Predicate{Name: api.FactNode, IDs: []biscuit.Term{biscuit.String(peerID.String())}}}, + {Predicate: biscuit.Predicate{Name: api.FactClientPeerID, IDs: []biscuit.Term{biscuit.String(peerID.String())}}}, + {Predicate: biscuit.Predicate{Name: api.FactExpiration, IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}}}, + } + for _, f := range append(base, facts...) { + if err := builder.AddAuthorityFact(f); err != nil { + t.Fatal(err) + } + } + b, err := builder.Build() + if err != nil { + t.Fatal(err) + } + token, err := b.Serialize() + if err != nil { + t.Fatal(err) + } + return token, pub +} + +// TestAuthorizeHTTPFacts pins that method(), path(), host() and port() are +// injected from the RequestContext and decide both a narrowed grant and a +// node's local attenuation, and that a request without HTTP facts does not +// match a narrowed grant. +func TestAuthorizeHTTPFacts(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + + caller := peer.ID("caller-peer") + narrowed := api.BuildHTTPGrantFacts(&api.HTTPGrant{ + Service: "egress://api.github.com", + Methods: []string{"GET"}, + Paths: []string{"/repos/acme/*"}, + }) + token, pub := mintFor(t, caller, append(narrowed, api.MarkerFact(api.FactTargetUnrestricted))...) + + node := &SamNode{ + Store: store, + trustedKeys: []TrustedKey{{Key: pub, ReceivedAt: time.Now()}}, + BiscuitTimeout: 500 * time.Millisecond, + } + request := func(method, path string) RequestContext { + rc := RequestContext{PeerID: caller, Protocol: "/libp2p-http", Target: "egress://api.github.com", + Egress: &EgressFacts{Host: "api.github.com", Port: 443}} + if method != "" { + rc.HTTP = &HTTPRequestFacts{Method: method, Path: path} + } + return rc + } + + if err := node.Authorize(token, request("GET", "/repos/acme/x"), pub); err != nil { + t.Errorf("GET under the granted prefix: %v", err) + } + if err := node.Authorize(token, request("POST", "/repos/acme/x"), pub); err == nil { + t.Error("POST was allowed on a GET-only grant") + } + if err := node.Authorize(token, request("GET", "/user"), pub); err == nil { + t.Error("a path outside the grant was allowed") + } + if err := node.Authorize(token, request("CONNECT", ""), pub); err == nil { + t.Error("a tunnel was allowed on a narrowed grant") + } + if err := node.Authorize(token, request("", ""), pub); err == nil { + t.Error("a request without HTTP facts was allowed on a narrowed grant") + } + + // Local attenuation sees the same facts. + cfg, err := CompleteNodeConfig(api.NodeConfig{Attenuation: api.Attenuation{Policies: []string{ + `deny if port($p), !($p == 443);`, + `deny if host("payroll.internal.example.com");`, + `deny if path($p), $p.starts_with("/repos/acme/secret");`, + }}}) + if err != nil { + t.Fatal(err) + } + node.nodeConfig = cfg + if err := node.Authorize(token, request("GET", "/repos/acme/x"), pub); err != nil { + t.Errorf("attenuation denied a request it should not: %v", err) + } + if err := node.Authorize(token, request("GET", "/repos/acme/secret/1"), pub); err == nil { + t.Error("attenuation on path() did not fire") + } + rc := request("GET", "/repos/acme/x") + rc.Egress.Port = 8080 + if err := node.Authorize(token, rc, pub); err == nil { + t.Error("attenuation on port() did not fire") + } +} + +// TestAuthorizeLocalIsSelfTargeted: a node authorizing a local client's +// request on its own credential passes the target check without a target +// grant, and nothing else is relaxed. +func TestAuthorizeLocalIsSelfTargeted(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + + self := peer.ID("self-peer") + grant := biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactGrantedServiceExact, + IDs: []biscuit.Term{biscuit.String("egress"), biscuit.String("api.github.com")}}} + token, pub := mintFor(t, self, grant) + node := &SamNode{Store: store, trustedKeys: []TrustedKey{{Key: pub, ReceivedAt: time.Now()}}, BiscuitTimeout: 500 * time.Millisecond} + + local := RequestContext{PeerID: self, Protocol: "local-api", Target: "egress://api.github.com", + HTTP: &HTTPRequestFacts{Method: "GET", Path: "/"}, Local: true} + if err := node.Authorize(token, local, pub); err != nil { + t.Errorf("local request on own credential denied: %v", err) + } + remote := local + remote.Local = false + if err := node.Authorize(token, remote, pub); err == nil { + t.Error("the target check was skipped for a request that is not local") + } + other := local + other.Target = "egress://other.example" + if err := node.Authorize(token, other, pub); err == nil { + t.Error("Local relaxed the service grant") + } +} + +// TestEgressServiceProxiesWithTheNodesCredential pins what the destination +// sees: the node's credential from the secrets directory, none of the +// caller's headers, the path as requested; and that a missing credential is +// refused at Init. +func TestEgressServiceProxiesWithTheNodesCredential(t *testing.T) { + var got *http.Request + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Clone(context.Background()) + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + secrets := t.TempDir() + if err := os.WriteFile(filepath.Join(secrets, "github-eu"), []byte("ghp_token\n"), 0o600); err != nil { + t.Fatal(err) + } + d := &api.EgressDestination{Name: "api.github.com", TargetUrl: upstream.URL, Credential: "github-eu"} + svc, err := newEgressService(d, secrets) + if err != nil { + t.Fatal(err) + } + if err := svc.Init(context.Background()); err != nil { + t.Fatal(err) + } + if facts := egressFactsFor(svc); facts == nil || facts.Host != "api.github.com" || facts.Port != mustPort(t, upstream.URL) { + t.Errorf("egressFactsFor = %+v", facts) + } + + req := httptest.NewRequest(http.MethodGet, "/repos/acme/x?state=open", nil) + req.Header.Set("Authorization", "Bearer the-callers-token") + req.Header.Set("Cookie", "session=the-callers-session") + req.Header.Set(api.HeaderSamAgent, "reviewer.acme.example") + req.Header.Set(api.HeaderPeerID, "12D3KooWCaller") + req.Header.Set("X-Forwarded-For", "10.0.0.1") + req.Header.Set("Accept", "application/json") + rr := httptest.NewRecorder() + svc.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + if got.Header.Get("Authorization") != "Bearer ghp_token" { + t.Errorf("upstream Authorization = %q, want the node's credential", got.Header.Get("Authorization")) + } + for _, h := range []string{"Cookie", api.HeaderSamAgent, api.HeaderPeerID, "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { + if got.Header.Get(h) != "" { + t.Errorf("upstream saw %s=%q", h, got.Header.Get(h)) + } + } + if got.Header.Get("Accept") != "application/json" { + t.Errorf("an ordinary header was dropped") + } + if got.URL.RequestURI() != "/repos/acme/x?state=open" { + t.Errorf("upstream path = %q", got.URL.RequestURI()) + } + + // Rotation by the platform applies without a restart. + if err := os.WriteFile(filepath.Join(secrets, "github-eu"), []byte("user:pass"), 0o600); err != nil { + t.Fatal(err) + } + svc.Handler().ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + if !strings.HasPrefix(got.Header.Get("Authorization"), "Basic ") { + t.Errorf("rotated credential not applied: %q", got.Header.Get("Authorization")) + } + + // A credential that disappears after registration is a configuration + // error the client can tell from the destination's own answer. + if err := os.Remove(filepath.Join(secrets, "github-eu")); err != nil { + t.Fatal(err) + } + rr = httptest.NewRecorder() + svc.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + if rr.Code != http.StatusBadGateway || rr.Header().Get("Proxy-Status") != "sam-node; error=proxy_configuration_error" { + t.Errorf("missing credential at request time: %d %q", rr.Code, rr.Header().Get("Proxy-Status")) + } + + // A credential the platform did not deliver is refused at Init. + missing, err := newEgressService(&api.EgressDestination{Name: "x.example", Credential: "absent"}, secrets) + if err != nil { + t.Fatal(err) + } + if err := missing.Init(context.Background()); err == nil || !strings.Contains(err.Error(), "absent") { + t.Errorf("Init with a missing credential: %v", err) + } + // No credential named: the destination is reached anonymously. + anon, err := newEgressService(&api.EgressDestination{Name: "x.example", TargetUrl: upstream.URL}, secrets) + if err != nil { + t.Fatal(err) + } + if err := anon.Init(context.Background()); err != nil { + t.Fatal(err) + } + anon.Handler().ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + if got.Header.Get("Authorization") != "" { + t.Errorf("anonymous destination received %q", got.Header.Get("Authorization")) + } +} + +func mustPort(t *testing.T, rawURL string) int { + t.Helper() + svc, err := newEgressService(&api.EgressDestination{Name: "p.example", TargetUrl: rawURL}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + return svc.port() +} + +// TestApplyEgressAssignmentsReconciles: assignments are registered, changed +// ones replaced, withdrawn ones unregistered, and one broken assignment does +// not stop the others. +func TestApplyEgressAssignmentsReconciles(t *testing.T) { + secrets := t.TempDir() + if err := os.WriteFile(filepath.Join(secrets, "cred"), []byte("tok"), 0o600); err != nil { + t.Fatal(err) + } + node := &SamNode{services: NewServiceRegistry(&fakeDHT{}, time.Second), config: Options{SecretsDir: secrets}} + ctx := context.Background() + assignments := func(outcome string) float64 { + return counterValue(t, egressAssignmentsTotal.WithLabelValues(outcome)) + } + registeredBefore, withdrawnBefore, refusedBefore := assignments("registered"), assignments("withdrawn"), assignments("refused") + + err := node.applyEgressAssignments(ctx, []*api.EgressDestination{ + {Name: "a.example", Credential: "cred"}, + {Name: "b.example"}, + {Name: "broken.example", Credential: "missing"}, + }) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("expected the broken assignment to be reported, got %v", err) + } + if names := egressNames(node); !equalStrings(names, []string{"a.example", "b.example"}) { + t.Fatalf("registered %v", names) + } + if got := assignments("registered") - registeredBefore; got != 2 { + t.Errorf("registered counter moved by %v, want 2", got) + } + if got := assignments("refused") - refusedBefore; got != 1 { + t.Errorf("refused counter moved by %v, want 1", got) + } + + // b changes its target, a is withdrawn, c appears. + if err := node.applyEgressAssignments(ctx, []*api.EgressDestination{ + {Name: "b.example", TargetUrl: "http://b.internal:8080"}, + {Name: "c.example"}, + }); err != nil { + t.Fatal(err) + } + if names := egressNames(node); !equalStrings(names, []string{"b.example", "c.example"}) { + t.Fatalf("registered %v", names) + } + b, _ := node.services.GetTyped(api.ServiceType_SERVICE_TYPE_EGRESS, "b.example") + if facts := egressFactsFor(b); facts.Port != 8080 { + t.Errorf("b was not replaced: %+v", facts) + } + if got := assignments("withdrawn") - withdrawnBefore; got != 1 { + t.Errorf("withdrawn counter moved by %v, want 1", got) + } +} + +func egressNames(node *SamNode) []string { + var names []string + for _, info := range node.services.List(api.ServiceType_SERVICE_TYPE_EGRESS) { + names = append(names, info.GetName()) + } + return names +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + seen := map[string]bool{} + for _, g := range got { + seen[g] = true + } + for _, w := range want { + if !seen[w] { + return false + } + } + return true +} + +// TestNodeConfigRefusesEgressServices: a destination is the control plane's +// to assign, so the node config cannot declare one. +func TestNodeConfigRefusesEgressServices(t *testing.T) { + _, err := CompleteNodeConfig(api.NodeConfig{Services: []api.ServiceConfig{{Type: "egress", Name: "api.github.com", TargetURL: "https://api.github.com"}}}) + if err == nil || !strings.Contains(err.Error(), "assigned by the control plane") { + t.Fatalf("CompleteNodeConfig accepted an egress service: %v", err) + } + _, err = NewServiceFromRequest(&api.RegisterServiceRequest{ + Service: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_EGRESS, Name: "api.github.com"}, + Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: "https://api.github.com"}, + }) + if err == nil || !strings.Contains(err.Error(), "assigned by the control plane") { + t.Fatalf("NewServiceFromRequest accepted an egress service: %v", err) + } +} diff --git a/internal/node/middleware.go b/internal/node/middleware.go index 435953c1..2d627df5 100644 --- a/internal/node/middleware.go +++ b/internal/node/middleware.go @@ -50,6 +50,35 @@ type RequestContext struct { // So it is attribution, not proof. Policy that cares should also constrain // which peers may speak for which agent namespaces. Agent string + + // HTTP is set when the node handles the request as HTTP: the method as + // received and the path as the backend sees it. Injected as method() and + // path() facts, taken from the wire and never from the caller's token. A + // tunnel the node opens without terminating HTTP carries Method "CONNECT" + // and an empty Path. Nil on a stream that carries no HTTP request. + HTTP *HTTPRequestFacts + + // Egress is set on a request for an egress destination: the hostname and + // port the node connects to, injected as host() and port() facts. + Egress *EgressFacts + + // Local marks a request this node makes for one of its own clients over the + // local API, for a destination it serves itself. The caller is then this + // node, evaluated on its own credential, and the target check is satisfied + // because a node is always allowed to reach itself. Never set from the wire. + Local bool +} + +// HTTPRequestFacts is what an HTTP request contributes to policy. +type HTTPRequestFacts struct { + Method string + Path string +} + +// EgressFacts is the destination of an egress request. +type EgressFacts struct { + Host string + Port int } // recoverStreamHandler isolates a panic while processing untrusted peer bytes @@ -280,6 +309,33 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. // Enforce client_peer_id matches connection_peer_id authorizer.AddCheck(api.BaselineReplayCheck) + // The request as the wire carried it. Present only when there is an HTTP + // request, so a grant narrowed to methods and paths (PolicyRole.http) has + // nothing to match on a bare stream and fails closed. + if req.HTTP != nil { + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactMethod, + IDs: []biscuit.Term{biscuit.String(req.HTTP.Method)}, + }}) + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactPath, + IDs: []biscuit.Term{biscuit.String(req.HTTP.Path)}, + }}) + } + if req.Egress != nil { + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactHost, + IDs: []biscuit.Term{biscuit.String(req.Egress.Host)}, + }}) + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactPort, + IDs: []biscuit.Term{biscuit.Integer(req.Egress.Port)}, + }}) + } + if req.Local { + authorizer.AddFact(api.MarkerFact(api.FactTargetUnrestricted)) + } + identity.EnforceExpiration(authorizer) // Inject facts from our own identity token to support target matching @@ -307,6 +363,9 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. for _, r := range api.BaselineRules { authorizer.AddRule(r) } + for _, r := range api.BaselineHTTPRules { + authorizer.AddRule(r) + } // Apply Dynamic Mesh Policy Rules n.MeshPolicyMu.RLock() @@ -319,7 +378,8 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. err = authorizer.Authorize() if err != nil { - logger.Errorf("Authorizer failure: %v, token: %s", err, b.String()) + logger.Infow("Audit Traceability", append(req.auditFields(), "decision", "deny", "reason", err.Error())...) + logger.Debugf("Authorizer failure: %v, token: %s", err, b.String()) logger.Debugf("Authorizer state: %s", authorizer.PrintWorld()) return err } @@ -353,18 +413,37 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. } } - logger.Infow("Audit Traceability", - "peer_id", req.PeerID.String(), + logger.Infow("Audit Traceability", append(req.auditFields(), + "decision", "allow", "user", userStr, "email", emailStr, "role", roleStr, - "target", req.Target, - "protocol", req.Protocol, - ) + )...) return nil } +// auditFields is what every authorization decision logs about the request: +// who asked, for what, and the request facts policy saw. One line per +// decision, allow or deny, is the audit trail of the PEP. +func (req RequestContext) auditFields() []any { + fields := []any{ + "peer_id", req.PeerID.String(), + "target", req.Target, + "protocol", req.Protocol, + } + if req.Agent != "" { + fields = append(fields, "agent", req.Agent) + } + if req.HTTP != nil { + fields = append(fields, "method", req.HTTP.Method, "path", req.HTTP.Path) + } + if req.Egress != nil { + fields = append(fields, "host", req.Egress.Host, "port", req.Egress.Port) + } + return fields +} + func (n *SamNode) injectIdentityFacts(authorizer biscuit.Authorizer, pubKey ed25519.PublicKey) error { ourIdentity := n.GetIdentity() if ourIdentity == nil { diff --git a/internal/node/node.go b/internal/node/node.go index 7ef2d962..c8d8398b 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -172,7 +172,11 @@ type SamNode struct { keysMu sync.RWMutex MeshPolicyRules []biscuit.Rule MeshPolicyMu sync.RWMutex - rateLimiter *ratelimit.PeerRateLimiter + // pendingEgress holds assignments that arrived before Start created the + // service registry (SyncControlPlane runs first); Start applies them. + pendingEgress []*api.EgressDestination + pendingEgressMu sync.Mutex + rateLimiter *ratelimit.PeerRateLimiter // handshakeLimiter bounds /sam/auth attempts per peer separately from // rateLimiter, so a peer's authenticated traffic cannot starve its own // re-authentication and vice versa. @@ -567,6 +571,7 @@ func (n *SamNode) Start(ctx context.Context) error { n.services = NewServiceRegistry(n.DHT, n.config.BackendProbeTimeout) n.services.reprovideNow = n.triggerReprovide + n.applyPendingEgress(ctx) var authenticated bool var fatalAuthErr error @@ -2168,14 +2173,28 @@ func (n *SamNode) StartIngressServer(ctx context.Context) error { Protocol: "/libp2p-http", Target: target, Agent: agentClaim(r.Header.Get(api.HeaderSamAgent)), + // The path policy sees is the one the backend will see, decided + // here so it can never be the routing prefix. + HTTP: &HTTPRequestFacts{Method: r.Method, Path: "/" + upstreamPath}, + } + if serviceType == api.ServiceType_SERVICE_TYPE_EGRESS { + if svc, ok := n.services.GetTyped(serviceType, serviceName); ok { + reqCtx.Egress = egressFactsFor(svc) + } } // Verify authorization if err := n.VerifyBiscuitToken(biscuitBytes, reqCtx); err != nil { logger.Warnf("[Ingress] AuthZ Denied for %s: %v", remotePeer, err) - http.Error(w, "Authorization failed", http.StatusForbidden) + if serviceType == api.ServiceType_SERVICE_TYPE_EGRESS { + recordEgressDecision(serviceName, egressOutcomeDeny) + } + refuse(w, http.StatusForbidden, "Authorization failed", proxyStatusDenied) return } + if serviceType == api.ServiceType_SERVICE_TYPE_EGRESS { + recordEgressDecision(serviceName, egressOutcomeAllow) + } // Strip the biscuit header so it doesn't leak to the backend service r.Header.Del(api.HeaderSamBiscuit) diff --git a/internal/node/options.go b/internal/node/options.go index ed19fee8..d67eeb52 100644 --- a/internal/node/options.go +++ b/internal/node/options.go @@ -87,6 +87,10 @@ type Options struct { // is tight enough that even simple interpreted-language MCP servers can // miss it on first spawn. BackendProbeTimeout time.Duration + // SecretsDir is where the node resolves the credential names the control + // plane assigns with egress destinations: one file per name, put there by + // the platform (a Secret volume, a vault agent). Zero uses DefaultSecretsDir. + SecretsDir string // CatalogReportInterval specifies how often the node self-reports its // locally registered services to the control plane (POST // /nodes/catalog), so an admin can see mesh-wide service topology @@ -147,6 +151,9 @@ func (o *Options) Default() { if o.ControlPlaneSyncInterval == 0 { o.ControlPlaneSyncInterval = DefaultControlPlaneSyncInterval } + if o.SecretsDir == "" { + o.SecretsDir = DefaultSecretsDir + } if o.CatalogReportInterval <= 0 { o.CatalogReportInterval = 1 * time.Minute } diff --git a/internal/node/service.go b/internal/node/service.go index d167579d..321b166a 100644 --- a/internal/node/service.go +++ b/internal/node/service.go @@ -130,6 +130,8 @@ func NewServiceFromRequest(req *api.RegisterServiceRequest) (Service, error) { return &InferenceService{baseService: baseService{info: info, backend: req.Backend}}, nil case api.ServiceType_SERVICE_TYPE_A2A: return &A2AService{baseService: baseService{info: info, backend: req.Backend}}, nil + case api.ServiceType_SERVICE_TYPE_EGRESS: + return nil, fmt.Errorf("service %q: egress destinations are assigned by the control plane (PolicyConfig.egress), not registered on a node", info.GetName()) default: return nil, fmt.Errorf("unspecified or unsupported service type: %v", info.Type) } diff --git a/internal/node/sidecar.go b/internal/node/sidecar.go index 2453083f..0cf6338b 100644 --- a/internal/node/sidecar.go +++ b/internal/node/sidecar.go @@ -93,6 +93,15 @@ func StartSidecarServer(node *SamNode, addr, socketPath, token, certFile, keyFil mux.Handle("/v1/chat/completions", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(facade.handleCompletions)))) mux.Handle("/v1/completions", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(facade.handleCompletions)))) + // Egress destinations this node serves, for local clients. Same gate as + // the facade: an SDK sends the sidecar token as its api_key. Whatever + // Authorization survives the gate is dropped by the egress handler, which + // presents the node's own credential to the destination. Not behind + // withMeshConnection: the destination is outside the mesh. + mux.Handle("/egress/", withAuth(token, true, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleLocalEgress(node, w, r) + }))) + // Mount MCP handler mcpHandler := NewMCPHandler(node) mux.Handle("/", withAuth(token, true, withMeshConnection(node, mcpHandler))) diff --git a/internal/sambox/route.go b/internal/sambox/route.go index 82087dcb..f6582d7e 100644 --- a/internal/sambox/route.go +++ b/internal/sambox/route.go @@ -127,6 +127,17 @@ func (p *EgressPolicy) Allows(host string) bool { return false } +// AllowsLiteral reports whether an address the guest dialled without ever +// resolving a name may be reached. Only an exact entry can say so: a +// wildcard names a DNS zone, and an address is in no zone. +func (p *EgressPolicy) AllowsLiteral(addr string) bool { + if p == nil { + return false + } + _, ok := p.exact[api.NormalizeMeshHost(addr)] + return ok +} + // Router classifies destinations arriving on the sandbox boundary. type Router struct { // Egress is the allowlist for destinations outside the mesh. Nil denies @@ -159,10 +170,17 @@ func (r *Router) Route(dst Destination) (Route, error) { return Route{Kind: RouteMeshService, ServiceURI: serviceURI, Destination: dst}, nil } - // Literal addresses are not special-cased: they carry no name, so they are - // allowed only by an exact entry. CIDR ranges are deliberately not - // supported yet; adding them is a policy-language decision, not a routing - // one. + // An address carries no name. The guest stack forwards one when a flow + // was opened to an address it never resolved, so policy has nothing to + // decide on but the address itself: only an exact entry allows it. CIDR + // ranges are deliberately not supported; adding them is a policy-language + // decision, not a routing one. + if !dst.IsName { + if !r.Egress.AllowsLiteral(dst.Name) { + return Route{}, fmt.Errorf("%w: %s is an address, not a name", ErrNotAllowed, dst.Name) + } + return Route{Kind: RouteExternal, Destination: dst}, nil + } if !r.Egress.Allows(dst.Name) { return Route{}, fmt.Errorf("%w: %s", ErrNotAllowed, dst.Name) } diff --git a/internal/sambox/route_test.go b/internal/sambox/route_test.go index c18a67dc..abe2c96d 100644 --- a/internal/sambox/route_test.go +++ b/internal/sambox/route_test.go @@ -91,6 +91,35 @@ func TestRouteClassification(t *testing.T) { // TestMeshNamesIgnoreEgressPolicy pins that mesh routing is not reachable // through the allowlist: a mesh name is authorized by mesh policy, and an // operator listing it under egress must not change how it is routed. +// TestLiteralAddressesNeedAnExactEntry: the guest stack forwards an address +// when a flow was opened to one it never resolved. Policy is written on +// names, so a wildcard cannot cover it; only an exact entry does, and with no +// policy at all it is denied like everything else. +func TestLiteralAddressesNeedAnExactEntry(t *testing.T) { + policy, err := NewEgressPolicy([]string{"*.3.4", "198.51.100.7", "api.github.com"}) + if err != nil { + t.Fatalf("NewEgressPolicy: %v", err) + } + r := &Router{Egress: policy} + + for _, addr := range []string{"1.2.3.4", "2001:db8::1", "10.0.0.1"} { + if _, err := r.Route(Destination{Name: addr, Port: 443, IsName: false}); !errors.Is(err, ErrNotAllowed) { + t.Errorf("Route(%q as address) = %v, want ErrNotAllowed", addr, err) + } + } + got, err := r.Route(Destination{Name: "198.51.100.7", Port: 5432, IsName: false}) + if err != nil || got.Kind != RouteExternal { + t.Errorf("an exactly listed address = %+v, %v; want RouteExternal", got, err) + } + // The same string as a name is still matched as a name. + if _, err := r.Route(Destination{Name: "x.3.4", Port: 443, IsName: true}); err != nil { + t.Errorf("a name under the wildcard: %v", err) + } + if _, err := (&Router{}).Route(Destination{Name: "198.51.100.7", Port: 443, IsName: false}); !errors.Is(err, ErrNotAllowed) { + t.Errorf("an address with no policy = %v, want ErrNotAllowed", err) + } +} + func TestMeshNamesIgnoreEgressPolicy(t *testing.T) { policy, err := NewEgressPolicy([]string{"*.sam.alt"}) if err != nil { From f86f7ed254e8ac38ada47352fda1fc2198a08515 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 20:03:38 +0000 Subject: [PATCH 4/6] sdk: inject method and path and apply the HTTP narrowing rules Both authorizers take the request's method and the path as the backend sees it, inject them as method() and path() facts, and add the baseline HTTP rules, so a grant narrowed by PolicyRole.http decides the same way under a JS or Python provider as under sam-node. The HTTP ingress passes them from the wire; a stream that carries no HTTP request passes nothing and a narrowed grant fails closed there. --- sdk/js/src/authorizer.test.ts | 21 +++++++++++++++++- sdk/js/src/authorizer.ts | 17 ++++++++++++++ sdk/js/src/libp2p-http-node.ts | 2 +- sdk/js/src/libp2p-http.test.ts | 4 ++-- sdk/js/src/libp2p-http.ts | 16 ++++++++++++-- sdk/python/src/agent_mesh/authorizer.py | 13 +++++++++++ sdk/python/src/agent_mesh/libp2p_http.py | 4 ++++ sdk/python/tests/test_authorizer.py | 28 +++++++++++++++++++++++- 8 files changed, 98 insertions(+), 7 deletions(-) diff --git a/sdk/js/src/authorizer.test.ts b/sdk/js/src/authorizer.test.ts index b528e1c4..9860edbb 100644 --- a/sdk/js/src/authorizer.test.ts +++ b/sdk/js/src/authorizer.test.ts @@ -155,11 +155,30 @@ test("an agent claim is accepted only inside a granted namespace", async () => { await authorizeCaller(request(nodeToken(CALLER)), options(NODE_ROLE_GRANTS)); }); +test("a grant narrowed by PolicyRole.http follows the request's method and path", async () => { + // Rendered as the control plane renders a role with + // http: [{service: "mcp://calc", methods: ["GET"], paths: ["/v1/*"]}]: + // the plain grant is withheld, the narrowed facts take its place. + const rules = [ + `http_granted_service_exact("mcp", "calc") <- role("sam:role:node")`, + `granted_method("mcp", "calc", ["GET"]) <- role("sam:role:node")`, + `granted_path_prefix("mcp", "calc", "/v1/") <- role("sam:role:node")`, + `target_unrestricted(true) <- role("sam:role:node")`, + ]; + const http = (method: string, path: string): AuthorizeRequest => ({ ...request(nodeToken(CALLER)), method, path }); + await authorizeCaller(http("GET", "/v1/models"), options(rules)); + await assert.rejects(authorizeCaller(http("POST", "/v1/models"), options(rules)), AuthorizationError); + await assert.rejects(authorizeCaller(http("GET", "/v2/models"), options(rules)), AuthorizationError); + // A tunnel, and a stream that carries no HTTP request: neither matches. + await assert.rejects(authorizeCaller(http("CONNECT", ""), options(rules)), AuthorizationError); + await assert.rejects(authorizeCaller(request(nodeToken(CALLER)), options(rules)), AuthorizationError); +}); + test("every baseline item parses in biscuit-wasm", () => { for (const c of [BASELINE_DATALOG.time_check, BASELINE_DATALOG.replay_check, BASELINE_DATALOG.target_check, BASELINE_DATALOG.agent_check]) { wasm.Check.fromString(c); } - for (const r of [...BASELINE_DATALOG.rules, ...BASELINE_DATALOG.agent_rules, ...BASELINE_DATALOG.target_fact_rules]) { + for (const r of [...BASELINE_DATALOG.rules, ...BASELINE_DATALOG.http_rules, ...BASELINE_DATALOG.agent_rules, ...BASELINE_DATALOG.target_fact_rules]) { wasm.Rule.fromString(r); } for (const p of [...BASELINE_DATALOG.policies, BASELINE_DATALOG.allow_if_true]) { diff --git a/sdk/js/src/authorizer.ts b/sdk/js/src/authorizer.ts index efe843f3..2b5ac840 100644 --- a/sdk/js/src/authorizer.ts +++ b/sdk/js/src/authorizer.ts @@ -33,6 +33,14 @@ export interface AuthorizeRequest { protocol: string; /** The agent the caller says it acts for; its own claim, checked against its grants. */ agent?: string; + /** + * The HTTP method and the path as the backend sees it, when the request is + * HTTP. Both are injected together; a request without them (a stream that + * carries no HTTP request) does not match a grant narrowed by + * PolicyRole.http. + */ + method?: string; + path?: string; } export interface ProviderAuthorizerOptions { @@ -122,6 +130,12 @@ export async function authorizeCaller(req: AuthorizeRequest, options: ProviderAu fact(`${BASELINE_DATALOG.fact_connection_peer_id}({p})`, { p: req.peerId }); fact(timeFact(now)); + // The request as the wire carried it, never as the caller describes it. + if (req.method !== undefined) { + fact(`${BASELINE_DATALOG.fact_method}({m})`, { m: req.method }); + fact(`${BASELINE_DATALOG.fact_path}({p})`, { p: req.path ?? "" }); + } + // The caller's word about which agent it acts for, limited to the agent // namespaces its own token grants. if (req.agent) { @@ -148,6 +162,9 @@ export async function authorizeCaller(req: AuthorizeRequest, options: ProviderAu for (const r of BASELINE_DATALOG.rules) { b.addRule(wasm.Rule.fromString(r)); } + for (const r of BASELINE_DATALOG.http_rules) { + b.addRule(wasm.Rule.fromString(r)); + } for (const r of options.policyRules()) { b.addRule(wasm.Rule.fromString(r)); } diff --git a/sdk/js/src/libp2p-http-node.ts b/sdk/js/src/libp2p-http-node.ts index 3b4bbebc..eb6da96f 100644 --- a/sdk/js/src/libp2p-http-node.ts +++ b/sdk/js/src/libp2p-http-node.ts @@ -97,7 +97,7 @@ function nodeHeaders(req: http.IncomingMessage): Headers { async function serveListener(req: http.IncomingMessage, res: http.ServerResponse, endpoint: A2AEndpoint, listener: NodeRequestListener, options: ProviderOptions): Promise { const remotePeer = (req.socket as unknown as StreamSocket).remotePeer; - const admission = await admitIngress({ target: req.url ?? "/", headers: nodeHeaders(req), remotePeer }, endpoint, options); + const admission = await admitIngress({ method: req.method ?? "GET", target: req.url ?? "/", headers: nodeHeaders(req), remotePeer }, endpoint, options); if ("status" in admission) { res.writeHead(admission.status, { "content-type": "text/plain; charset=utf-8" }); res.end(admission.text + "\n"); diff --git a/sdk/js/src/libp2p-http.test.ts b/sdk/js/src/libp2p-http.test.ts index 0bc05183..424c8548 100644 --- a/sdk/js/src/libp2p-http.test.ts +++ b/sdk/js/src/libp2p-http.test.ts @@ -342,11 +342,11 @@ test("a dot segment is refused however it is spelled", async () => { // Nothing in options is consulted before the path check. const options = providerOptions(new Uint8Array()); for (const target of ["/a2a/agent/../x", "/a2a/agent/./x", "/a2a/agent/%2e%2e/x", "/a2a/agent/%2E%2E/x", "/a2a/agent/.%2e/x", "/a2a/agent/%2e/x", "/a2a/%2e%2e/other/x?q=1"]) { - assert.deepEqual(await admitIngress({ target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 400, text: "Invalid path" }, target); + assert.deepEqual(await admitIngress({ method: "GET", target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 400, text: "Invalid path" }, target); } // Not dot segments: the request reaches the next check, the missing biscuit. for (const target of ["/a2a/agent/%2e%2ex/x", "/a2a/agent/..x/x", "/a2a/agent/x?p=../y"]) { - assert.deepEqual(await admitIngress({ target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 401, text: "Missing X-Sam-Biscuit header" }, target); + assert.deepEqual(await admitIngress({ method: "GET", target, headers: new Headers(), remotePeer: "peer" }, endpoint, options), { status: 401, text: "Missing X-Sam-Biscuit header" }, target); } }); diff --git a/sdk/js/src/libp2p-http.ts b/sdk/js/src/libp2p-http.ts index 8b544641..6508fcb8 100644 --- a/sdk/js/src/libp2p-http.ts +++ b/sdk/js/src/libp2p-http.ts @@ -132,6 +132,8 @@ function hasDotSegment(path: string): boolean { /** What the ingress looks at before anything reaches the agent. */ export interface IngressRequest { + /** The request method as it came off the wire. */ + method: string; /** The request target as it came off the wire, path and query. */ target: string; headers: Headers; @@ -199,7 +201,17 @@ export async function admitIngress(req: IngressRequest, endpoint: A2AEndpoint, o let verified: VerifiedBiscuit; try { verified = await authorizeCaller( - { biscuit, peerId: req.remotePeer, targetService, protocol: HTTP_PROTOCOL, agent: req.headers.get(HEADER_SAM_AGENT) ?? "" }, + { + biscuit, + peerId: req.remotePeer, + targetService, + protocol: HTTP_PROTOCOL, + agent: req.headers.get(HEADER_SAM_AGENT) ?? "", + // The path as the backend sees it, decided before authorization so + // path() is what policy meant, never the routing prefix. + method: req.method, + path: "/" + upstreamPath, + }, options, ); } catch (err) { @@ -302,7 +314,7 @@ async function serveIngress(stream: Stream, remotePeer: string, endpoint: A2AEnd return; } headOnly = head.method === "HEAD"; - const admission = await admitIngress({ target: head.target, headers: head.headers, remotePeer }, endpoint, options); + const admission = await admitIngress({ method: head.method, target: head.target, headers: head.headers, remotePeer }, endpoint, options); if ("status" in admission) { response = refusalResponse(admission); } else if ("listener" in endpoint.target) { diff --git a/sdk/python/src/agent_mesh/authorizer.py b/sdk/python/src/agent_mesh/authorizer.py index 33575815..5145cc9d 100644 --- a/sdk/python/src/agent_mesh/authorizer.py +++ b/sdk/python/src/agent_mesh/authorizer.py @@ -54,6 +54,12 @@ class AuthorizeRequest: protocol: str # The agent the caller says it acts for; its own claim, checked against its grants. agent: str = "" + # The HTTP method and the path as the backend sees it, when the request is + # HTTP. Both are injected together; a request without them (a stream that + # carries no HTTP request) does not match a grant narrowed by + # PolicyRole.http. + method: Optional[str] = None + path: str = "" @dataclass(frozen=True) @@ -105,6 +111,11 @@ def authorize_caller(req: AuthorizeRequest, options: ProviderAuthorizerOptions) b.add_fact(ba.Fact(BASELINE_DATALOG["fact_connection_peer_id"] + "({p})", {"p": req.peer_id})) b.add_fact(ba.Fact(BASELINE_DATALOG["fact_time"] + "({now})", {"now": now})) + # The request as the wire carried it, never as the caller describes it. + if req.method is not None: + b.add_fact(ba.Fact(BASELINE_DATALOG["fact_method"] + "({m})", {"m": req.method})) + b.add_fact(ba.Fact(BASELINE_DATALOG["fact_path"] + "({p})", {"p": req.path})) + # The caller's word about which agent it acts for, limited to the agent # namespaces its own token grants. if req.agent: @@ -126,6 +137,8 @@ def authorize_caller(req: AuthorizeRequest, options: ProviderAuthorizerOptions) b.add_policy(ba.Policy(p)) for r in BASELINE_DATALOG["rules"]: b.add_rule(ba.Rule(r)) + for r in BASELINE_DATALOG["http_rules"]: + b.add_rule(ba.Rule(r)) for r in options.policy_rules(): b.add_rule(ba.Rule(r)) diff --git a/sdk/python/src/agent_mesh/libp2p_http.py b/sdk/python/src/agent_mesh/libp2p_http.py index f470fa0d..9d3e2c87 100644 --- a/sdk/python/src/agent_mesh/libp2p_http.py +++ b/sdk/python/src/agent_mesh/libp2p_http.py @@ -238,6 +238,10 @@ def plain(status: int, text: str) -> tuple[int, dict[str, str], bytes]: target_service=target_service, protocol=str(HTTP_PROTOCOL), agent=headers.get(HEADER_SAM_AGENT, ""), + # The path as the backend sees it, decided before authorization + # so path() is what policy meant, never the routing prefix. + method=request.method.decode("latin-1"), + path="/" + upstream_path, ), options.authorizer, ) diff --git a/sdk/python/tests/test_authorizer.py b/sdk/python/tests/test_authorizer.py index bbd988dd..dffc7f89 100644 --- a/sdk/python/tests/test_authorizer.py +++ b/sdk/python/tests/test_authorizer.py @@ -136,10 +136,36 @@ def test_agent_claim_only_inside_a_granted_namespace(): authorize_caller(request(node_token(CALLER)), options(NODE_ROLE_GRANTS)) +def test_narrowed_grant_follows_the_requests_method_and_path(): + # Rendered as the control plane renders a role with + # http: [{service: "mcp://calc", methods: ["GET"], paths: ["/v1/*"]}]: + # the plain grant is withheld, the narrowed facts take its place. + rules = [ + 'http_granted_service_exact("mcp", "calc") <- role("sam:role:node")', + 'granted_method("mcp", "calc", ["GET"]) <- role("sam:role:node")', + 'granted_path_prefix("mcp", "calc", "/v1/") <- role("sam:role:node")', + 'target_unrestricted(true) <- role("sam:role:node")', + ] + + def http(method: str, path: str) -> AuthorizeRequest: + return AuthorizeRequest(biscuit=node_token(CALLER), peer_id=CALLER, target_service="mcp://calc", protocol="/libp2p-http", method=method, path=path) + + authorize_caller(http("GET", "/v1/models"), options(rules)) + with pytest.raises(AuthorizationError): + authorize_caller(http("POST", "/v1/models"), options(rules)) + with pytest.raises(AuthorizationError): + authorize_caller(http("GET", "/v2/models"), options(rules)) + # A tunnel, and a stream that carries no HTTP request: neither matches. + with pytest.raises(AuthorizationError): + authorize_caller(http("CONNECT", ""), options(rules)) + with pytest.raises(AuthorizationError): + authorize_caller(request(node_token(CALLER)), options(rules)) + + def test_every_baseline_item_parses_in_biscuit_python(): for c in (BASELINE_DATALOG["time_check"], BASELINE_DATALOG["replay_check"], BASELINE_DATALOG["target_check"], BASELINE_DATALOG["agent_check"]): ba.Check(c) - for r in BASELINE_DATALOG["rules"] + BASELINE_DATALOG["agent_rules"] + BASELINE_DATALOG["target_fact_rules"]: + for r in BASELINE_DATALOG["rules"] + BASELINE_DATALOG["http_rules"] + BASELINE_DATALOG["agent_rules"] + BASELINE_DATALOG["target_fact_rules"]: ba.Rule(r) for p in BASELINE_DATALOG["policies"] + [BASELINE_DATALOG["allow_if_true"]]: ba.Policy(p) From 4007c803511ce6f76245aa8c2669dd462826212a Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 20:03:39 +0000 Subject: [PATCH 5/6] tests: the egress destination CUJ with real binaries An admin posts one policy document; a node holding the serving role picks the destination up with no configuration of its own; an application on its host reaches it through the local API; a member whose role is narrowed to GET under a prefix is allowed and refused accordingly through the serving node; the destination sees the node's credential and nothing of the callers. --- tests/integration/egress_test.go | 328 +++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 tests/integration/egress_test.go diff --git a/tests/integration/egress_test.go b/tests/integration/egress_test.go new file mode 100644 index 00000000..b3c7c0f3 --- /dev/null +++ b/tests/integration/egress_test.go @@ -0,0 +1,328 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration_test + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/google/sam/api" +) + +// TestEgressDestinationCUJ is the egress story end to end, with real +// binaries: an admin posts one policy document naming destinations, the +// credentials to use and the roles or labels that serve them; a node +// selected by role and by label starts serving them with no configuration +// of its own, and refuses the one whose credential the platform did not +// deliver; an application on that node's host reaches a destination through +// the local API, naming the agent it acts for; another mesh member reaches +// it through the PEP node, narrowed to the methods and paths its role +// allows; each destination sees the node's credential for it and nothing of +// the callers; and a policy update withdraws a destination without a +// restart. +func TestEgressDestinationCUJ(t *testing.T) { + nodeBin := buildBinary(t, "./cmd/sam-node") + tmpDir := t.TempDir() + oidcURL, mintToken := startCustomMockOIDC(t) + + // Two destinations, standing in for api.github.com and an internal API. + recordingServer := func(seen *[]*http.Request, mu *sync.Mutex) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + *seen = append(*seen, r.Clone(context.Background())) + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + } + var mu sync.Mutex + var seen, seenInternal []*http.Request + upstream := recordingServer(&seen, &mu) + defer upstream.Close() + internal := recordingServer(&seenInternal, &mu) + defer internal.Close() + + // One document: who serves what with which credential, and who may call + // it how. api.github.com is served by role, the internal API by label; the + // third destination names a credential nobody delivered. The contractor + // may only GET under /repos/acme/. + policyFile := filepath.Join(tmpDir, "policies.yaml") + policyYAML := fmt.Sprintf(`roles: + - name: pep + allowed_targets: ["*"] + allowed_labels: ["site=eu"] + allowed_agents: ["*.acme.example"] + - name: contractor + allowed_services: ["egress://api.github.com"] + allowed_targets: ["*"] + http: + - service: "egress://api.github.com" + methods: ["GET"] + paths: ["/repos/acme/*"] +bindings: + - role: pep + members: ["user:pep-user"] + - role: contractor + members: ["user:contractor-user"] + - role: sam:role:node + members: ["user:pep-user", "user:contractor-user"] +egress: + - name: api.github.com + target_url: %q + credential: github-eu + served_by: ["pep"] + - name: mam.internal.example.com + target_url: %q + credential: mam-basic + served_by: ["site=eu"] + - name: broken.example + target_url: %q + credential: never-delivered + served_by: ["pep"] +`, upstream.URL, internal.URL, internal.URL) + if err := os.WriteFile(policyFile, []byte(policyYAML), 0o644); err != nil { + t.Fatal(err) + } + cpPort, cleanupCP := startControlPlaneAndRouter(t, tmpDir, oidcURL, mintToken, policyFile) + defer cleanupCP() + controlPlane := fmt.Sprintf("http://127.0.0.1:%d", cpPort) + + // The platform delivered two credentials to the PEP host as files, in + // the two forms the node accepts; the third destination's is missing. + secrets := filepath.Join(tmpDir, "secrets") + if err := os.MkdirAll(secrets, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(secrets, "github-eu"), []byte("ghp_pep_secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(secrets, "mam-basic"), []byte("svc:s3cret"), 0o600); err != nil { + t.Fatal(err) + } + + // The PEP declares the label the second destination selects on. Nothing + // about egress appears here. + pepConfig := filepath.Join(tmpDir, "pep.yaml") + if err := os.WriteFile(pepConfig, []byte("version: \"v1alpha1\"\nlabels:\n site: eu\n"), 0o644); err != nil { + t.Fatal(err) + } + + pepToken := "test-token" + pep := launchNode(t, nodeBin, os.Environ(), filepath.Join(tmpDir, "pep"), "run", + "--control-plane", controlPlane, + "--data-dir", filepath.Join(tmpDir, "pep"), + "--api-token-path", tokenPath(t, pepToken), + "--jwt", mintToken(map[string]interface{}{"sub": "pep-user", "roles": []string{api.RoleNode}}), + "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", + "--listen", "/ip4/127.0.0.1/tcp/0", + "--allow-loopback", + "--discovery-interval", "100ms", + "--control-plane-sync-interval", "1s", + "--config", pepConfig, + "--secrets-dir", secrets, + ) + pepAPI := pep.waitForAPI(t) + + callerToken := "test-token" + caller := launchNode(t, nodeBin, os.Environ(), filepath.Join(tmpDir, "caller"), "run", + "--control-plane", controlPlane, + "--data-dir", filepath.Join(tmpDir, "caller"), + "--api-token-path", tokenPath(t, callerToken), + "--jwt", mintToken(map[string]interface{}{"sub": "contractor-user", "roles": []string{api.RoleNode}}), + "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", + "--listen", "/ip4/127.0.0.1/tcp/0", + "--allow-loopback", + "--discovery-interval", "100ms", + ) + callerAPI := caller.waitForAPI(t) + connectPeer(t, callerAPI, pep.p2pAddr) + waitForDHTPeers(t, pepAPI) + pepPeer := extractPeerID(pep.p2pAddr) + + client := &http.Client{Timeout: 10 * time.Second} + do := func(t *testing.T, method, rawURL, token string, headers map[string]string) int { + t.Helper() + req, err := http.NewRequest(method, rawURL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set(api.HeaderSamAuthentication, "Bearer "+token) + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, rawURL, err) + } + _ = resp.Body.Close() + return resp.StatusCode + } + + // The PEP picked its assignments up from the control plane and announced + // them: the caller can discover egress://api.github.com like any service. + waitForDiscoverableService(t, callerAPI, callerToken, "egress", "api.github.com") + + t.Run("an application on the PEP host, through the local API", func(t *testing.T) { + // The app is configured with the node's API as its base URL and the + // API token as its bearer, the way it would hold a provider key. + req, _ := http.NewRequest(http.MethodGet, "http://"+pepAPI+"/egress/api.github.com/repos/acme/dubbing/pulls?state=open", nil) + req.Header.Set("Authorization", "Bearer "+pepToken) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("local GET: %d, want 204", resp.StatusCode) + } + // The serving role is not narrowed, so any method passes here. + if got := do(t, http.MethodPost, "http://"+pepAPI+"/egress/api.github.com/repos/acme/x", pepToken, nil); got != http.StatusNoContent { + t.Errorf("local POST on an un-narrowed grant: %d, want 204", got) + } + if got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/other.example/x", pepToken, nil); got != http.StatusNotFound { + t.Errorf("a destination not assigned: %d, want 404", got) + } + // Selected by the node's attested label, with a Basic credential. + if got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/mam.internal.example.com/v2/clips/99", pepToken, nil); got != http.StatusNoContent { + t.Errorf("a destination served by label: %d, want 204", got) + } + // Assigned, but its credential was never delivered: refused at + // registration, so it is not served, and the others still are. + if got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/broken.example/x", pepToken, nil); got != http.StatusNotFound { + t.Errorf("a destination with a missing credential: %d, want 404", got) + } + // The agent the client names is checked against the node's grant. + agent := func(id string) map[string]string { return map[string]string{api.HeaderSamAgent: id} } + if got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/api.github.com/repos/acme/x", pepToken, agent("reviewer-7.acme.example")); got != http.StatusNoContent { + t.Errorf("an agent inside the granted namespace: %d, want 204", got) + } + if got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/api.github.com/repos/acme/x", pepToken, agent("intruder.evil-acme.example")); got != http.StatusForbidden { + t.Errorf("an agent outside it: %d, want 403", got) + } + }) + + t.Run("a mesh member, through the PEP node", func(t *testing.T) { + base := fmt.Sprintf("http://%s/sam/%s/egress/api.github.com", callerAPI, pepPeer) + for _, tc := range []struct { + name string + method string + path string + want int + }{ + {"GET under the granted prefix", http.MethodGet, "/repos/acme/dubbing/pulls?state=open", http.StatusNoContent}, + {"POST is outside the narrowed grant", http.MethodPost, "/repos/acme/dubbing/pulls", http.StatusForbidden}, + {"a path outside the narrowed grant", http.MethodGet, "/user", http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := do(t, tc.method, base+tc.path, callerToken, nil); got != tc.want { + t.Errorf("%s %s: %d, want %d", tc.method, tc.path, got, tc.want) + } + }) + } + }) + + // What the destinations saw: the node's credential for each, the requested + // path, and no trace of the callers. + mu.Lock() + if len(seen) == 0 || len(seenInternal) == 0 { + mu.Unlock() + t.Fatalf("the destinations saw %d and %d requests", len(seen), len(seenInternal)) + } + for _, r := range seen { + if r.Header.Get("Authorization") != "Bearer ghp_pep_secret" { + t.Errorf("api.github.com saw Authorization %q, want the PEP's bearer credential", r.Header.Get("Authorization")) + } + if r.URL.Path == "/user" || strings.Contains(r.URL.Path, "..") { + t.Errorf("a refused request reached the destination: %s", r.URL.Path) + } + } + for _, r := range seenInternal { + if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("svc:s3cret")) { + t.Errorf("internal API saw Authorization %q, want the PEP's Basic credential", r.Header.Get("Authorization")) + } + if r.URL.Path != "/v2/clips/99" { + t.Errorf("internal API saw path %q", r.URL.Path) + } + } + for _, r := range append(append([]*http.Request{}, seen...), seenInternal...) { + for name := range r.Header { + if strings.HasPrefix(name, "X-Sam-") || strings.HasPrefix(name, "X-Forwarded-") || name == api.HeaderPeerID { + t.Errorf("a destination saw %s", name) + } + } + } + if seen[0].URL.RequestURI() != "/repos/acme/dubbing/pulls?state=open" { + t.Errorf("first request URI = %q", seen[0].URL.RequestURI()) + } + mu.Unlock() + + t.Run("a policy update withdraws a destination without a restart", func(t *testing.T) { + // The admin reassigns api.github.com to nodes that do not exist. The + // control plane publishes the update; the PEP re-syncs and withdraws. + current, err := os.ReadFile(policyFile) + if err != nil { + t.Fatal(err) + } + updated := strings.Replace(string(current), `served_by: ["pep"]`, `served_by: ["site=nowhere"]`, 1) + if updated == string(current) { + t.Fatal("policy file did not contain the assignment to change") + } + if err := os.WriteFile(policyFile, []byte(updated), 0o644); err != nil { + t.Fatal(err) + } + injectPolicyYAML(t, cpPort, "test-admin-token", policyFile) + + deadline := time.Now().Add(8 * time.Second) + for { + got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/api.github.com/repos/acme/x", pepToken, nil) + if got == http.StatusNotFound { + break + } + if time.Now().After(deadline) { + t.Fatalf("api.github.com still served after the update: %d", got) + } + time.Sleep(200 * time.Millisecond) + } + // The destination selected by label is untouched. + if got := do(t, http.MethodGet, "http://"+pepAPI+"/egress/mam.internal.example.com/v2/clips/1", pepToken, nil); got != http.StatusNoContent { + t.Errorf("the other destination after the update: %d, want 204", got) + } + }) + + // The node's log tells the operator what it serves, what it refused and + // why, and what it withdrew. + log := pep.log() + for _, want := range []string{ + "Serving egress://api.github.com", + "Serving egress://mam.internal.example.com", + `credential "never-delivered"`, + "Withdrawn egress://api.github.com", + } { + if !strings.Contains(log, want) { + t.Errorf("PEP log lacks %q", want) + } + } + if strings.Contains(log, "Serving egress://broken.example") { + t.Error("PEP served a destination whose credential was never delivered") + } +} From 76e0ea298f9dc1f424f8a9f9c4a616f0ce8c7dc4 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 26 Sep 2026 20:03:39 +0000 Subject: [PATCH 6/6] docs: egress destinations, HTTP grants and the request facts Policy reference: the egress section, the http field, the egress service type, the evaluation order with the request facts, and the new vocabulary. Node API: the /egress route. Node config: egress is not declared there; the request facts available to attenuation. Control plane: GET /egress. sam-node: --secrets-dir. Authorization concept: why a caller cannot forge a fact and how a new requirement is a fact or a rule and not a schema change. A guide walks one request from the admin's document to the destination. Every Datalog snippet is written in the form biscuit-go parses: the dialect has no !=, a negation is !(...). Fixes #479 --- site/content/docs/concepts/authorization.md | 41 +++- .../docs/guides/egress-destinations.md | 193 ++++++++++++++++++ site/content/docs/reference/control-plane.md | 1 + site/content/docs/reference/node-api.md | 41 ++++ site/content/docs/reference/node-config.md | 14 +- site/content/docs/reference/policy.md | 129 +++++++++++- site/content/docs/reference/sam-node.md | 1 + 7 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 site/content/docs/guides/egress-destinations.md diff --git a/site/content/docs/concepts/authorization.md b/site/content/docs/concepts/authorization.md index e698e18e..15a73683 100644 --- a/site/content/docs/concepts/authorization.md +++ b/site/content/docs/concepts/authorization.md @@ -113,7 +113,10 @@ Biscuit authorizer and adds the following, in this order: 1. **The request**: `service("mcp", "calculator")` for the requested service, and `connection_peer_id(P)` from the authenticated connection. If the caller named an agent, the agent claim is added together with the - check that the caller's own token grants that agent namespace. + check that the caller's own token grants that agent namespace. When the + node handles the request as HTTP it adds `method("GET")` and + `path("/v1/models")`, the path as the backend will see it; for a + destination outside the mesh it adds `host(...)` and `port(...)`. 2. **The baseline checks**: `client_peer_id($id), connection_peer_id($id)` (the token belongs to the peer that presents it), and the expiration check against the current time. @@ -128,7 +131,9 @@ Biscuit authorizer and adds the following, in this order: 5. **The baseline policies**: `allow if service($t,$n), granted_service_exact($t,$n)` and the equivalent policies for sets, prefixes, suffixes, per-type and global wildcards, plus the target check - `allow_network_target(...) or target_unrestricted(true)`. + `allow_network_target(...) or target_unrestricted(true)`, and the rules + that turn an [HTTP grant](../../reference/policy/#http-grants) into a + service grant when the request's method and path match it. 6. **The synced mesh policy rules** described above. Biscuit evaluates every `check` and requires all of them to pass. It then @@ -142,6 +147,35 @@ Before any of this, the connection itself is gated. A peer on the ban list is dropped at the transport layer, and a peer whose credential does not verify under a trusted signing key cannot name a service at all. +## Why a caller cannot forge a fact + +Two kinds of fact meet in the authorizer. The facts in the credential's +authority block were written and signed by the control plane: roles, +grants, labels, the peer the token is bound to. The facts about the request +(`service`, `method`, `path`, `host`, `port`, `agent`, `connection_peer_id`, +`time`) are added by the node that received the request, from what arrived +on the wire. A caller writes neither. It cannot change the authority block +without breaking the signature, and it cannot put a fact into the request +set because the node computes that set itself. + +Biscuit does let a holder append a block to a token. That is how a token is +attenuated: a holder can add a check that narrows what the token does. The +facts of an appended block are visible only to that block's own checks and +never to the authorizer's policies, so an appended `role("admin")` grants +nothing. `internal/identity`'s +`TestAttenuationBlockFactsAreInvisibleToTheAuthorizer` pins this, and nodes +refuse an inbound token that carries appended blocks at all. + +This is what lets the policy grow without a schema change. A requirement +that needs a new dimension is a new fact or a new rule, written in the same +language that the existing grants compile to. A role's `custom_datalog` can +mint `tier("contractor")` into every holder's credential, a node's +`attenuation` can then say `deny if tier("contractor"), method($m), !($m == "GET")`, +and neither `PolicyRole` nor any wire message changed. The structured fields +(`allowed_services`, `allowed_agents`, `http`, ...) are the common cases, +compiled to Datalog by the control plane; `custom_datalog` and +`attenuation` are the same engine written by hand. + ## Local rules The `attenuation` block in `sam-node.yaml` gives the hosting node the final @@ -212,3 +246,6 @@ the namespace belongs to the node's role and not to the agent. The name. - [Node configuration reference](../../reference/node-config/): the `attenuation`, `labels` and `egress` blocks. +- [Reaching services outside the mesh](../../guides/egress-destinations/): + the node as a policy enforcement point for an application's outbound + HTTP calls. diff --git a/site/content/docs/guides/egress-destinations.md b/site/content/docs/guides/egress-destinations.md new file mode 100644 index 00000000..ad330a73 --- /dev/null +++ b/site/content/docs/guides/egress-destinations.md @@ -0,0 +1,193 @@ +--- +title: "Reaching services outside the mesh" +linkTitle: "Egress destinations" +weight: 3 +--- + +An agentic application calls APIs that are not on the mesh: a source +forge, a ticketing system, an internal REST service, a model provider. This +guide puts a `sam-node` in front of such a destination as a policy +enforcement point. The application changes one base URL. The admin writes +one policy document. The node decides every request on the method, the +path and the caller, holds the credential the destination needs, and keeps +it out of the application. + +You need a control plane you administer, one enrolled node that will serve +the destination, and the credential the destination expects (an API token) +available to that node's host as a file. + +## What the admin writes + +Everything is in the mesh policy. The `egress` section names the +destination, the credential and the nodes that serve it. A role narrows who +may call it and how. + +```json +{ + "roles": [ + { + "name": "sam:role:node", + "allowed_services": ["system://sam.catalog"], + "allowed_targets": ["*"] + }, + { + "name": "pep", + "allowed_targets": ["*"] + }, + { + "name": "contractor", + "allowed_services": ["egress://api.github.com"], + "allowed_targets": ["*"], + "http": [ + { "service": "egress://api.github.com", "methods": ["GET"], "paths": ["/repos/acme/*"] } + ] + } + ], + "bindings": [ + { "role": "sam:role:node", "members": ["group:platform", "group:external"] }, + { "role": "pep", "members": ["group:platform"] }, + { "role": "contractor", "members": ["group:external"] } + ], + "egress": [ + { "name": "api.github.com", "credential": "github-eu", "served_by": ["pep"] } + ] +} +``` + +- `egress[].name` is the destination hostname. It is the service name in + grants (`egress://api.github.com`) and the name a caller looks up. +- `egress[].credential` is a name, never a value. The serving node reads + the file `/etc/sam/secrets/github-eu` (or under `--secrets-dir`) and + presents its content as `Authorization: Bearer `; `user:pass` + is sent as HTTP Basic. Whatever puts secrets in files on the host, a + Kubernetes Secret volume, a vault agent, a secrets-store CSI driver, + delivers it; the control plane never sees it. +- `egress[].served_by` selects the serving nodes by role or by attested + label (`site=eu`). The control plane grants the destination to those + nodes, so a serving node authorizes local requests on its own credential. +- `roles[].http` narrows a grant to methods and paths. Here a contractor may + `GET` under `/repos/acme/` and nothing else. The other fields of a role + are unchanged; see the [policy reference](../../reference/policy/). + +Post it: + +```bash +curl -sS -X POST "$CONTROL_PLANE/policies" \ + -H "Authorization: Bearer $(cat admin-token)" \ + -H 'Content-Type: application/json' \ + --data @policy.json +``` + +## What the node does + +Nothing in `sam-node.yaml` changes, and `type: egress` is refused there. A +node holding the `pep` role pulls its assignments from the control plane on +the same schedule as the mesh policy, and sooner when the policy changes. +For each destination that selects it, the node registers the service, +announces `egress://api.github.com` on the mesh, and checks that the named +credential is readable. Its log shows: + +```text +[Egress] Serving egress://api.github.com -> https://api.github.com (assigned by the control plane) +[Egress] Assignments: 1 assigned, 1 registered, 0 unchanged, 0 withdrawn, 0 refused +``` + +A destination whose credential is not in the secrets directory is refused +and logged; the node keeps serving the others. When the admin removes a +destination or its `served_by` no longer selects the node, the node +withdraws the service on its next sync. Every sync that changes or refuses +something logs the summary line above; the control plane, for its part, +warns when a posted destination selects no enrolled node at all. + +## What the application does + +The application on the serving node's host is configured with the node's +API as the base URL of the destination and the node's API token as its +bearer, in the place it would hold a provider key: + +```bash +export GITHUB_API_URL=http://127.0.0.1:8080/egress/api.github.com +export GITHUB_TOKEN=$(cat /etc/sam/api-token) +``` + +A request then travels like this: + +```text +app GET /egress/api.github.com/repos/acme/dubbing/pulls?state=open + Authorization: Bearer +node accepts the API token; drops it + facts: service("egress","api.github.com") method("GET") + path("/repos/acme/dubbing/pulls") host("api.github.com") port(443) + authorizes on its own credential, with its attenuation + GET https://api.github.com/repos/acme/dubbing/pulls?state=open + Authorization: Bearer +``` + +The destination sees the node's credential and none of the application's +headers (`Authorization`, `Cookie`, `X-*`). A `403` is a policy decision; a +`404` is a destination this node was not assigned. Both carry +`Proxy-Status: sam-node; error=...`, so your client can tell them from an +answer the destination sent. + +Every decision, allowed or denied, is one `Audit Traceability` line in the +node's log with the peer, the role, the agent, the method, the path, the +host and the port policy saw, and the decision. That line is the audit +trail of the PEP; the destination's own logs see only the node. + +The node's `/metrics` endpoint counts the same events, so you can alert +without reading logs: + +| Metric | Labels | Counts | +|---|---|---| +| `sam_node_egress_decisions_total` | `destination`, `outcome` | requests for a destination, by outcome: `allow`, `deny`, `not_assigned` (the node does not serve that name) and `credential_unavailable` (the credential file could not be read when the request arrived) | +| `sam_node_egress_assignments_total` | `outcome` | assignments applied from the control plane: `registered`, `withdrawn` and `refused` | + +`sam_node_services_registered{type="egress"}` is the number of destinations +the node serves right now. A `refused` assignment or a `credential_unavailable` +decision means the platform did not deliver a credential the policy names. + +A client that names the agent it acts for sends `X-Sam-Agent`. The claim is +checked against the node's `allowed_agents` grant and reaches policy as +`agent()`, as it does on every other path. + +## What another mesh member does + +A member whose role grants `egress://api.github.com` reaches the same +destination through its own node, which finds the serving node by name: + +```bash +curl -sS -H "X-Sam-Authentication: Bearer $TOKEN" \ + "http://127.0.0.1:8080/sam/$PEP_PEER_ID/egress/api.github.com/repos/acme/dubbing/pulls" +``` + +The serving node evaluates the member's credential. With the policy above a +member in `group:external` gets `204` on that request, `403` on a `POST` to +the same path, and `403` on `GET /user`. + +## Narrowing on the node + +The serving node's operator can refuse what the mesh policy allows, in +`sam-node.yaml`. The request facts are available there. The dialect has no +`!=`; a negation is `!`: + +```yaml +attenuation: + policies: + - 'deny if path($p), $p.starts_with("/repos/acme/vault/");' + - 'deny if group("external"), method($m), !($m == "GET");' + - 'deny if port($p), !($p == 443);' +``` + +## Limits + +- The node is the HTTP origin. A method and path decision needs the request + in the clear, which is the case here because the application talks plain + HTTP to the node. A tunnel the node opens without terminating HTTP carries + `method("CONNECT")` and an empty path, so a grant narrowed to methods or + paths denies it. +- The path is a prefix under the destination. A client that follows + absolute URLs returned by the destination (a `Link` header, a URL in a + body) leaves the node. Use a client that takes a base URL, or point it + back at the prefix. +- One destination is one hostname. A wildcard destination and a destination + reached by `CONNECT` from a sandbox are not part of this release. diff --git a/site/content/docs/reference/control-plane.md b/site/content/docs/reference/control-plane.md index d2da9b0f..9efe2000 100644 --- a/site/content/docs/reference/control-plane.md +++ b/site/content/docs/reference/control-plane.md @@ -76,6 +76,7 @@ names. Every instant in a response (`expire_time` and the like) is a | `POST /refresh` | `TokenRefreshRequest`, current credential as `Authorization: Bearer ` | Exchange a credential for a new one. Refuses a replayed (superseded) credential, a banned node, an expired session, and a credential signed by a retired key unless the node has `autonomous_recovery`. | | `POST /routers/lease` | `RouterLeaseRequest` (credential, addresses, telemetry) | Register or renew a router lease. Requires `role("sam:role:router")`. Announced addresses must end in the router's own peer ID. | | `GET /policies` | credential as `Authorization: Bearer ` | The mesh policy as `PolicyConfigGetResponse`: the Datalog rules a member adds to its authorizer, one per entry. Operators read the document at `GET /admin/policy`. | +| `GET /egress` | credential as `Authorization: Bearer ` | `EgressAssignmentsResponse`: the [egress destinations](../policy/#egress-destinations) whose `served_by` selects the calling node, by its roles or labels. A node registers and serves what it receives here. | | `POST /nodes/catalog` | `NodeCatalogReport`, credential as bearer | A node's report of the services it publishes, for the console. Display only. Never used for authorization. | ### Admin diff --git a/site/content/docs/reference/node-api.md b/site/content/docs/reference/node-api.md index 77a235c6..09f54102 100644 --- a/site/content/docs/reference/node-api.md +++ b/site/content/docs/reference/node-api.md @@ -35,6 +35,7 @@ proxy path does not accept it there. | `POST /v1/chat/completions`, `POST /v1/completions` | token | OpenAI-compatible inference, routed to a provider of the requested model. | | `GET /sam/service/discover` | token | Discover services on the mesh. | | `ANY /sam/{peer-id}/{type}/{name}[/{path}]` | token | Reverse proxy to one service on one peer. | +| `ANY /egress/{destination}[/{path}]` | token | A destination outside the mesh that this node serves, for local clients. See [Egress](#egress). | | `GET /sam/identity` | token, socket or mTLS only | This node's credential and the key it verifies under. | | `GET /sam/peer/{peer-id}/evidence` | token, socket or mTLS only | A peer's credential as this node last verified it. | | `GET /debug/*` | token | Operator diagnostics. These answer even when the mesh is unreachable. | @@ -205,6 +206,46 @@ here because nothing on this path forwards that header. Streaming responses are passed through. One provider can also be addressed directly, at `/sam//inference//v1/chat/completions`. +## Egress + +`/egress//` reaches a destination outside the mesh that +the control plane assigned to this node (an entry of the +[egress section](../policy/#egress-destinations) of the mesh policy whose +`served_by` selects it). The node is the HTTP origin: it authorizes the +request on its own credential, with the request's method and path and the +destination's host and port as facts, forwards it to the destination's +`target_url` with the credential the policy names, and returns the answer. + +```bash +curl -s --unix-socket $SOCK "http://localhost/egress/api.github.com/repos/acme/dubbing/pulls?state=open" +``` + +An application that takes a base URL for the API it calls points it at the +node, `http://127.0.0.1:8080/egress/api.github.com`, and sends the API +token as its bearer, the way it would send a provider key. The node accepts +the token in `Authorization` on this path and never forwards that header: +the destination sees the node's credential and none of the client's +headers. A client may name the agent it acts for in `X-Sam-Agent`; the +claim is checked against the node's `allowed_agents` grant and is visible +to policy as `agent()`. + +`403` is a policy decision: the node's role lacks the destination, an +`http` narrowing excludes the method or path, or the node's `attenuation` +refuses the request. `404` is a destination the control plane did not +assign to this node. Both carry a `Proxy-Status` header (RFC 9209) naming +the node as the source, `sam-node; error=http_request_denied` or +`error=destination_not_found`, so a client can tell them from a `403` or +`404` the destination itself sent, which carries none. A destination whose +credential file went missing after registration answers `502` with +`error=proxy_configuration_error`. The same destination is reachable from +another mesh member at `/sam//egress//` on that +member's node, authorized on the member's credential. + +The path is a prefix under the destination, so a client that follows +absolute URLs returned by the destination (a `Link` header, a URL in a +body) leaves the node. Point it back at the prefix, or use a client that +takes a base URL. + ## Identity evidence `GET /sam/identity` returns the node's credential (base64), the control diff --git a/site/content/docs/reference/node-config.md b/site/content/docs/reference/node-config.md index 94e7e3f3..d818d2c6 100644 --- a/site/content/docs/reference/node-config.md +++ b/site/content/docs/reference/node-config.md @@ -76,7 +76,7 @@ A list. Each entry has these keys: | Key | Required | Meaning | |---|---|---| -| `type` | yes | `mcp`, `inference` or `a2a`. | +| `type` | yes | `mcp`, `inference` or `a2a`. `egress` is refused here: a destination outside the mesh is assigned to nodes by the control plane, in the [egress section](../policy/#egress-destinations) of the mesh policy. | | `name` | yes | Unique on this node. DNS-style labels separated by dots (`db-reader`, `build.runner`). Policy grants refer to `type://name`. | | `description` | no | Shown in discovery results and in the console. | | `target_url` | `mcp`: this or `command`; `inference` and `a2a`: yes | Backend URL that the node proxies to. Embedded credentials (`http://user:pass@`) are refused. | @@ -99,9 +99,10 @@ Before a service is advertised, the node probes the backend (`--backend-probe-timeout`, 2 seconds). A backend that does not answer is not advertised, and the log records this. -Services exist only through this file. There is no runtime API that adds a -service. An agent with access to the node's API therefore cannot point the -mesh at a new backend. +Services exist only through this file and through the control plane's +egress assignments. There is no runtime API that adds a service. An agent +with access to the node's API therefore cannot point the mesh at a new +backend. ## `attenuation` @@ -129,6 +130,11 @@ Facts available: | `granted_service_*`, `granted_target_*`, `granted_agent_*` | The caller's grants. | | `target_fact($name, $value)` | This node's own identity facts, for target matching. | | `agent($id)` | The agent the caller says it acts for, when present. | +| `method($m)`, `path($p)` | The request, when it is HTTP: the method as received and the path as the backend sees it. On a tunnel, `CONNECT` and an empty path. Absent on a stream that carries no HTTP request. | +| `host($h)`, `port($n)` | The destination of a request for an egress destination. | + +The dialect has no `!=`; write a negation with `!`, as in +`deny if method($m), !($m == "GET")`. The mobile app has the same three lists in its settings, with the same syntax and the same errors. diff --git a/site/content/docs/reference/policy.md b/site/content/docs/reference/policy.md index 11157ad9..0e7669a4 100644 --- a/site/content/docs/reference/policy.md +++ b/site/content/docs/reference/policy.md @@ -6,10 +6,11 @@ aliases: - /docs/development/policy/ --- -The mesh policy is one document held by the control plane: a list of roles -and a list of bindings. It is posted as JSON (protojson of `PolicyConfig` in -`api/sam.proto`) to `POST /policies`, read back from `GET /admin/policy`, -edited in the console, or given to `sam-one --policy-file` for first boot. +The mesh policy is one document held by the control plane: a list of roles, +a list of bindings and a list of egress destinations. It is posted as JSON +(protojson of `PolicyConfig` in `api/sam.proto`) to `POST /policies`, read +back from `GET /admin/policy`, edited in the console, or given to +`sam-one --policy-file` for first boot. ```json { @@ -25,11 +26,23 @@ edited in the console, or given to `sam-one --policy-file` for first boot. "allowed_targets": ["group:dev-nodes", "node:12D3KooWSpecialNode"], "allowed_agents": ["*.dev.acme.example"], "custom_datalog": ["tier(\"standard\");"] + }, + { + "name": "contractor", + "allowed_services": ["egress://api.github.com"], + "allowed_targets": ["*"], + "http": [ + { "service": "egress://api.github.com", "methods": ["GET"], "paths": ["/repos/acme/*"] } + ] } ], "bindings": [ { "role": "sam:role:node", "members": ["group:engineering", "user:system:serviceaccount:sam-nodes:calc-mcp-sam-node"] }, - { "role": "developer", "members": ["group:engineering"] } + { "role": "developer", "members": ["group:engineering"] }, + { "role": "contractor", "members": ["group:external"] } + ], + "egress": [ + { "name": "api.github.com", "credential": "github-eu", "served_by": ["site=eu"] } ] } ``` @@ -48,11 +61,16 @@ notice. | `allowed_labels` | list of label patterns | Labels that a node holding this role may declare at enrollment. If absent, no labels may be declared. | | `allowed_agents` | list of agent patterns | Agent identifiers that a node holding this role may claim to act for. If absent, no agent may be named. | | `custom_datalog` | list of Datalog statements | Facts are minted into the credentials of holders. Rules are distributed to nodes and applied when a holder is verified. | +| `http` | list of HTTP grants | Narrows an `allowed_services` entry to HTTP methods and paths. See [HTTP grants](#http-grants). | ### Service patterns -`type://name`, where `type` is `mcp`, `inference`, `a2a` or `system`, and -`name` consists of dot-separated DNS-style labels. +`type://name`, where `type` is `mcp`, `inference`, `a2a`, `egress` or +`system`, and `name` consists of dot-separated DNS-style labels. For +`egress` the name is the hostname of a destination outside the mesh, written +lowercase, with no scheme, port or path: `egress://api.github.com/v3` is +rejected, because the path belongs in the role's `http` entry. See +[Egress destinations](#egress-destinations). | Pattern | Compiles to | Matches | |---|---|---| @@ -98,6 +116,76 @@ agents it hosts. | `key=*` | any value for that key | | `*` | any label | +### HTTP grants + +An entry of `http` narrows one `allowed_services` entry of the same role to +HTTP methods and paths. The service must be written exactly as it appears in +`allowed_services`; at least one of `methods` and `paths` must be set. + +| Field | Meaning | +|---|---| +| `service` | The `allowed_services` entry this narrows. | +| `methods` | Methods the holder may use, uppercase, such as `GET`. Empty means any method. | +| `paths` | Paths the holder may request, as the backend sees them. `/user` matches that path only; `/v2/public/*` matches every path under the prefix. Empty means any path. | + +```json +{ "service": "egress://api.github.com", "methods": ["GET", "HEAD"], "paths": ["/repos/acme/*", "/user"] } +``` + +The control plane compiles the entry into facts in the holder's credential +and withholds the plain service grant for that entry: + +```datalog +http_granted_service_exact("egress", "api.github.com") +granted_method("egress", "api.github.com", ["GET", "HEAD"]) +granted_path_prefix("egress", "api.github.com", "/repos/acme/") +granted_path_exact("egress", "api.github.com", ["/user"]) +``` + +Baseline rules on every node derive `granted_service_exact("egress", +"api.github.com")` from these facts only when the request's `method()` and +`path()` facts satisfy them. The ordinary allow policies then decide as they +do for any grant. The rules are positive, so a request that carries no HTTP +method (a tunnel, a non-HTTP stream) derives nothing and a narrowed grant +denies it. A role that holds the same service plainly, through another +entry or another role, is not narrowed: grants are a union. + +A node built before this field existed has no derivation rules, so it denies +a narrowed grant entirely rather than treating it as unrestricted. + +## Egress destinations + +An entry of `egress` is a destination outside the mesh that selected nodes +serve as `egress://`. The admin writes it once; each selected node +receives it at `GET /egress`, registers the service, announces it on the DHT +and forwards requests to it. Nodes hold no egress configuration of their +own, and `type: egress` in `sam-node.yaml` is refused. + +| Field | Meaning | +|---|---| +| `name` | The destination hostname, lowercase, without a port or a path. It is the service name in grants (`egress://`) and in the `service()` fact. One hostname; no wildcard. | +| `target_url` | Where the serving node forwards requests. Optional; `https://` when empty. `http` or `https`, no credential, no query. | +| `credential` | Name of the credential the serving node presents to the destination. The node reads the file `<--secrets-dir>/` (default `/etc/sam/secrets`): `TOKEN` is sent as `Authorization: Bearer TOKEN`, `user:pass` as HTTP Basic. The file is read on every request, so a rotation by the platform applies at once. The value never travels through the control plane. | +| `served_by` | Role names or `key=value` labels selecting the serving nodes. A node matches when any entry names one of its roles or attested labels. | + +The control plane renders one rule per `served_by` entry into the mesh +policy, `granted_service_exact("egress", "api.github.com") <- role("pep")` +or `<- label("site", "eu")`, so a serving node authorizes a local request +with its own credential. Every other caller needs `egress://` on its +own role. A destination that names a credential the platform did not deliver +to a node is refused by that node at registration and logged; the other +destinations are still served. + +On an egress request the destination node injects `host()` and `port()` +next to `method()` and `path()`, and the destination sees the node's +credential only: the caller's `Authorization`, `Cookie`, `X-Sam-*` and +`X-Forwarded-*` headers are removed. See [Node API](../node-api/#egress) +for how a local client reaches a destination. + +The control plane warns in its log when a posted destination selects no +enrolled node, since a selector with a typo is valid in form and would +otherwise surface only as `404` at the callers. + Keys match `[a-zA-Z0-9_.-]{1,63}`. Values are up to 255 characters with no `,`, `=` or control characters. Every label that a node declares must be permitted by a role it holds, or enrollment fails. Manual approval of a @@ -163,7 +251,9 @@ and removals within the credential TTL. At the destination node, in this order: 1. Facts for the request: `service($type, $name)`, `connection_peer_id($id)`, - `time($now)`, and `agent($id)` if a claim was made. + `time($now)`, and `agent($id)` if a claim was made. On an HTTP request, + `method($m)` and `path($p)`; on a request for an egress destination, + `host($h)` and `port($n)`. 2. Checks that always apply: `client_peer_id($id), connection_peer_id($id)`, `time($t), expiration($e), $t <= $e`, and `agent_authorized(true)` when an agent was named. @@ -171,7 +261,8 @@ At the destination node, in this order: 4. The node's `attenuation` rules, checks and policies. 5. Baseline policies: `allow if service($t,$n), granted_service_exact($t,$n)` and the set, prefix, suffix, per-type and global variants; the check - `allow_network_target($f,$v) or target_unrestricted(true)`. + `allow_network_target($f,$v) or target_unrestricted(true)`; the rules that + derive a service grant from an [HTTP grant](#http-grants). 6. The synced mesh policy rules. All checks must hold, and the first matching policy decides. Without a @@ -202,10 +293,30 @@ statements. | `granted_service_exact`, `_set`, `_prefix`, `_suffix`, `_all`, `_all_types` | type, name/set/pattern | control plane and node rules | | `granted_target_exact`, `_set`, `_prefix`, `_suffix`, `_all`, `_all_facts` | fact, value/set/pattern | control plane and node rules | | `granted_agent_exact`, `_set`, `_prefix`, `_suffix`, `_all` | pattern | control plane and node rules | +| `http_granted_service_exact`, `_prefix`, `_suffix`, `_all`, `_all_types` | type, name/pattern | control plane and node rules, for an `http` entry | +| `granted_method`, `granted_method_any` | type, key, set | control plane and node rules, for an `http` entry | +| `granted_path_exact`, `granted_path_prefix`, `granted_path_any` | type, key, set/prefix | control plane and node rules, for an `http` entry | | `service` | type, name | destination node, per request | | `connection_peer_id` | peer ID | destination node, per request | | `time` | date | destination node, per request | | `agent` | identifier | destination node, from the caller's claim | +| `method` | string | destination node, on an HTTP request: the method as received; `CONNECT` on a tunnel | +| `path` | string | destination node, on an HTTP request: the path as the backend sees it, leading slash, no query; empty on a tunnel | +| `host` | hostname | destination node, on an egress request | +| `port` | integer | destination node, on an egress request | | `target_fact` | fact, value | destination node, from its own credential | | `allow_network_target` | fact, value | derived by baseline rules | | `agent_authorized` | | derived by baseline rules | +| `http_method_ok`, `http_path_ok` | type, key | derived by baseline rules | + +A node's `attenuation` can refer to the request facts. The Datalog dialect +has no `!=`; a negation is written with `!`: + +```yaml +attenuation: + policies: + - 'deny if method($m), !($m == "GET");' + - 'deny if path($p), $p.starts_with("/admin/");' + - 'deny if port($p), !($p == 443);' + - 'deny if host("payroll.internal.example.com");' +``` diff --git a/site/content/docs/reference/sam-node.md b/site/content/docs/reference/sam-node.md index 24a30041..ad0d5f9b 100644 --- a/site/content/docs/reference/sam-node.md +++ b/site/content/docs/reference/sam-node.md @@ -112,6 +112,7 @@ enroll. | `--discovery-concurrency` | `10` | Concurrent catalog fetches during discovery. | | `--dht-provider-addr-ttl`, `--dht-max-record-age` | library defaults | DHT record lifetimes. | | `--backend-probe-timeout` | `2s` | How long a service backend may take to answer before the node declines to advertise it. Raise it for subprocesses that start slowly. | +| `--secrets-dir` | `/etc/sam/secrets` | Directory holding the credentials that the control plane's [egress destinations](../policy/#egress-destinations) name, one file per credential name. The platform puts the files there; the node reads a file on every request to the destination. | | `--control-plane-sync-interval` | `15m` | How often signing keys, the ban set, router addresses and the mesh policy are pulled from the control plane. Keep it well below the control plane's `--key-grace-period`; raise it on large meshes. | | `--key-grace-period` | `24h` | How long a rotated-out control plane key is still accepted for verifying peers. | | `--monitor-bootstrap` | `2m` | Delay before the router-connection monitor starts. |