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/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/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/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/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/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 { 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 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/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/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/_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"] 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) 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. | 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") + } +}