Add the egresspolicy evaluator and share the actor SPIFFE ID format

The egress gateway is about to enforce EgressPolicy, which means two
components now interpret the same hostname patterns and CIDRs: ateapi
when it validates a policy, and atenet when it matches one. Putting the
parsers in internal/egresspolicy and having ateapi's custom validators
call them keeps "accepted by the API" and "enforceable by the gateway"
the same set by construction. The package also compiles a policy once
and evaluates it the way the API describes, in rule order over whatever
the caller knows of the destination, first match wins: a hostname rule
never matches a destination with no name, and an IP rule never matches
one whose address the caller does not know. The decision says which
kind of rule matched, because the gateway sends the bytes to what that
rule checked. HasHostnameRules answers the question that follows, which
is whether a decision point holding only an address should refuse
outright or leave the destination to one that will see a name.

Hostname patterns now reject any name whose last label is all digits,
per RFC 1123 section 2.1, instead of consulting an IP parser. That is
the same set as before for every real address and additionally refuses
dotted-quad spellings such as "01.2.3.4" that no parser accepts.

The actor SPIFFE ID moves to internal/resources with a parser next to
the builder: ateapi mints it into the certificate's URI SAN and the
egress gateway reads it back as the actor identity on the legs inside
the tunnel, so both ends need the one definition.
This commit is contained in:
Lior Lieberman
2026-09-14 15:51:44 -07:00
committed by Bowei Du
parent 25c06b972f
commit e517df95d4
8 changed files with 844 additions and 28 deletions
+4 -12
View File
@@ -23,7 +23,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/url" "net/url"
"path"
"slices" "slices"
"time" "time"
@@ -672,17 +671,10 @@ func (s *RPCService) MintActorCertificate(ctx context.Context, req *ateapipb.Min
switch req.GetPurpose() { switch req.GetPurpose() {
case ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_ATUNNEL: case ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_ATUNNEL:
template = &x509.Certificate{ template = &x509.Certificate{
URIs: []*url.URL{ URIs: []*url.URL{resources.ActorSPIFFEID(resources.ActorRef{
{ Atespace: dbActor.GetMetadata().GetAtespace(),
Scheme: "spiffe", Name: dbActor.GetMetadata().GetName(),
// TODO(identity): Must be configurable per-install, so that each install can set it to a unique value. })},
Host: "substrate-actor.local",
// TODO(identity): Prefix with "atunnel" to prevent
// confusion between atunnel and an actor pretending to be
// an atunnel.
Path: path.Join("atespace", dbActor.GetMetadata().GetAtespace(), "actor", dbActor.GetMetadata().GetName()),
},
},
NotBefore: time.Now().Add(-5 * time.Minute), NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(time.Hour), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, KeyUsage: x509.KeyUsageDigitalSignature,
@@ -22,13 +22,13 @@ import (
"strings" "strings"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/internal/egresspolicy"
"github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/resources"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"k8s.io/apimachinery/pkg/api/operation" "k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/api/validate/content"
"k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
) )
@@ -202,10 +202,14 @@ func ValidateCustom_EgressRuleEffects_InjectStaticHeaders(_ context.Context, _ o
return errs return errs
} }
// Validation uses the parsers the egress gateway matches with, so what the
// API accepts and what the gateway can evaluate cannot drift apart.
func ValidateCustom_CIDRRule_Cidrs(_ context.Context, _ operation.Operation, p *field.Path, cidrs, _ []string) field.ErrorList { func ValidateCustom_CIDRRule_Cidrs(_ context.Context, _ operation.Operation, p *field.Path, cidrs, _ []string) field.ErrorList {
var errs field.ErrorList var errs field.ErrorList
for i, cidr := range cidrs { for i, cidr := range cidrs {
errs = append(errs, validation.IsValidCIDR(p.Index(i), cidr)...) if _, err := egresspolicy.ParseCIDR(cidr); err != nil {
errs = append(errs, field.Invalid(p.Index(i), cidr, "must be a canonical IPv4 or IPv6 prefix"))
}
} }
return errs return errs
} }
@@ -214,8 +218,7 @@ func validateHostnamePattern(raw string, p *field.Path) field.ErrorList {
if raw == "" { if raw == "" {
return field.ErrorList{field.Required(p, "")} return field.ErrorList{field.Required(p, "")}
} }
name := strings.TrimPrefix(raw, "*.") if _, err := egresspolicy.ParseHostnamePattern(raw); err != nil {
if len(content.IsDNS1123Subdomain(name)) != 0 || len(validation.IsValidIP(p, name)) == 0 {
return field.ErrorList{ return field.ErrorList{
field.Invalid(p, raw, "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), field.Invalid(p, raw, "must be a DNS hostname, optionally with a complete leftmost-label wildcard"),
} }
+2 -3
View File
@@ -76,9 +76,8 @@ type Handler struct {
actorIdentityRoots *x509.CertPool actorIdentityRoots *x509.CertPool
} }
// New builds the egress handler. actorIdentityRoots is the same trust bundle // New builds the egress handler. actorIdentityRoots is the egress listener's
// the egress listener uses as its trusted_ca; see verifyActorCertificate for // trusted_ca; see verifyActorCertificate for why it is checked again here.
// why the check is made again here.
func New(apiClient ateapipb.ControlClient, actorIdentityRoots *x509.CertPool) *Handler { func New(apiClient ateapipb.ControlClient, actorIdentityRoots *x509.CertPool) *Handler {
return &Handler{apiClient: apiClient, actorIdentityRoots: actorIdentityRoots} return &Handler{apiClient: apiClient, actorIdentityRoots: actorIdentityRoots}
} }
@@ -37,15 +37,10 @@ const (
TargetActorFilterStateAttribute = "filter_state['" + TargetActorFilterStateKey + "']" TargetActorFilterStateAttribute = "filter_state['" + TargetActorFilterStateKey + "']"
ConnectAuthorityFilterStateAttribute = "filter_state['" + ConnectAuthorityFilterStateKey + "']" ConnectAuthorityFilterStateAttribute = "filter_state['" + ConnectAuthorityFilterStateKey + "']"
// ActorIdentityFilterStateKey is the filter-state key holding the actor // ActorIdentityFilterStateKey holds the actor's SPIFFE ID
// identity the egress gateway read from the peer certificate it verified // (resources.ActorSPIFFEID), read from the peer certificate's URI SAN.
// against the actor-identity CA. Egress-only, and set and read entirely in // The outer CONNECT chain sets it from %DOWNSTREAM_PEER_URI_SAN% and
// manifests/ate-install/atenet-egress-with-sdsmint.yaml: the MITM access // shares it with the inner legs, which have no certificate of their own.
// logs stamp it, and the optional additional ext_proc service
// (hack/experimental-additional-egress-extproc.sh) requests it. No Go in
// this repository reads it — this handler authenticates the certificate
// itself — but it is part of the same namespace and drifts if it is not
// declared with the rest.
ActorIdentityFilterStateKey = "dev.ate.actor.identity" ActorIdentityFilterStateKey = "dev.ate.actor.identity"
// directionAttribute carries the Direction outright, for dataplanes that // directionAttribute carries the Direction outright, for dataplanes that
+292
View File
@@ -0,0 +1,292 @@
// 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 egresspolicy evaluates an Actor's EgressPolicy against a
// destination. ateapi validates patterns and CIDRs with the same parsers the
// gateway matches with, so the two cannot drift.
//
// The package is pure: no I/O, no logging.
package egresspolicy
import (
"errors"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
"k8s.io/apimachinery/pkg/api/validate/content"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)
// Destination is what a request or connection is going to, as far as the leg
// evaluating it can tell. A request the gateway can read has the Hostname it
// named, when that is a DNS name, and the IP the actor dialed, when the leg
// knows it; a connection has only IP and Port. A rule that needs a field the
// leg cannot supply never matches there.
type Destination struct {
// Hostname is the normalized DNS name: lowercase ASCII, no trailing dot.
// Empty when the destination was not named by a hostname.
Hostname string
// IP is the address that will be dialed, when known. Zero when unknown.
IP netip.Addr
// Port is the destination port, when known. Zero when unknown.
Port uint16
}
// Decision is the outcome of evaluating a policy against a Destination.
type Decision struct {
// Allowed reports whether some rule authorized the destination.
Allowed bool
// RuleIndex is the index of the first matching rule in the policy, or -1
// when nothing matched.
RuleIndex int
// Effects are the effects of the matching rule, when it is a hostname
// rule that declares any. Nil otherwise.
Effects *ateapipb.EgressRuleEffects
// ByName reports that a hostname rule matched: the destination was
// authorized on its name, so the name is what to dial.
ByName bool
}
// Policy is an EgressPolicy with its patterns and CIDRs parsed once, ready
// to be evaluated many times.
type Policy struct {
rules []compiledRule
}
type compiledRule struct {
hostnames []HostnamePattern
effects *ateapipb.EgressRuleEffects
cidrs []netip.Prefix
all bool
}
// Compile parses every pattern and CIDR in policy. ateapi validates with the
// same parsers, so nothing should fail here; an entry that does (an older
// ateapi accepted it) is dropped and reported, which fails closed because it
// only narrows an allow rule. The Policy is always usable.
func Compile(policy *ateapipb.EgressPolicy) (*Policy, []error) {
var errs []error
compiled := &Policy{}
for i, rule := range policy.GetRules() {
var cr compiledRule
switch {
case rule.GetHostnames() != nil:
for _, raw := range rule.GetHostnames().GetPatterns() {
pattern, err := ParseHostnamePattern(raw)
if err != nil {
errs = append(errs, fmt.Errorf("rules[%d].hostnames: %w", i, err))
continue
}
cr.hostnames = append(cr.hostnames, pattern)
}
cr.effects = rule.GetHostnames().GetEffects()
case rule.GetCidrs() != nil:
for _, raw := range rule.GetCidrs().GetCidrs() {
cidr, err := ParseCIDR(raw)
if err != nil {
errs = append(errs, fmt.Errorf("rules[%d].cidrs: %w", i, err))
continue
}
cr.cidrs = append(cr.cidrs, cidr)
}
case rule.GetAll() != nil:
cr.all = true
}
compiled.rules = append(compiled.rules, cr)
}
return compiled, errs
}
// RuleCount is the number of rules in the policy, dropped entries included.
// A policy with no rules can authorize nothing.
func (p *Policy) RuleCount() int { return len(p.rules) }
// HasHostnameRules reports whether any rule can match a hostname. A decision
// point that sees only an address needs this before refusing a connection
// whose requests might still be allowed by name.
func (p *Policy) HasHostnameRules() bool {
for _, rule := range p.rules {
if len(rule.hostnames) > 0 {
return true
}
}
return false
}
// Evaluate walks the rules in order and returns the first that matches dest.
// Only that rule's effects apply; a request is denied when no rule matches.
func (p *Policy) Evaluate(dest Destination) Decision {
for i, rule := range p.rules {
switch {
case rule.all:
return Decision{Allowed: true, RuleIndex: i}
case len(rule.hostnames) > 0:
if dest.Hostname == "" {
continue
}
for _, pattern := range rule.hostnames {
if pattern.Matches(dest.Hostname) {
return Decision{Allowed: true, RuleIndex: i, Effects: rule.effects, ByName: true}
}
}
case len(rule.cidrs) > 0:
if !dest.IP.IsValid() {
continue
}
ip := dest.IP.Unmap()
for _, cidr := range rule.cidrs {
if cidr.Contains(ip) {
return Decision{Allowed: true, RuleIndex: i}
}
}
}
}
return Decision{RuleIndex: -1}
}
// HostnamePattern is one parsed HostnameRule pattern: an exact name, or a
// wildcard standing in for the whole leftmost label.
type HostnamePattern struct {
// name is the exact name, or the suffix after "*." for a wildcard.
name string
wildcard bool
// dotSuffix is "." + name, built once so Matches does not allocate per call.
dotSuffix string
}
// ParseHostnamePattern parses a HostnameRule pattern. A pattern is a lowercase
// DNS-1123 subdomain, optionally prefixed with "*." to match exactly one
// non-empty leftmost label. IP literals, ports, URLs, trailing dots, and any
// other placement of "*" are rejected.
func ParseHostnamePattern(raw string) (HostnamePattern, error) {
name, wildcard := strings.CutPrefix(raw, "*.")
if !isHostname(name) {
return HostnamePattern{}, fmt.Errorf("%q is not a valid hostname pattern", raw)
}
pattern := HostnamePattern{name: name, wildcard: wildcard}
if wildcard {
pattern.dotSuffix = "." + name
}
return pattern, nil
}
// String returns the pattern in the form it was written.
func (p HostnamePattern) String() string {
if p.wildcard {
return "*." + p.name
}
return p.name
}
// Matches reports whether hostname, already normalized as by
// NormalizeAuthority, matches the pattern. "*.example.com" matches
// "api.example.com" but neither "example.com" nor "a.b.example.com".
func (p HostnamePattern) Matches(hostname string) bool {
if !p.wildcard {
return hostname == p.name
}
label, found := strings.CutSuffix(hostname, p.dotSuffix)
return found && label != "" && !strings.Contains(label, ".")
}
// ParseCIDR parses one CIDRRule entry. Only the canonical form is
// accepted: no leading zeros, no bits set beyond the prefix length, IPv6 in
// RFC 5952 lowercase compressed notation, and no IPv4-mapped IPv6 addresses.
func ParseCIDR(raw string) (netip.Prefix, error) {
cidr, err := netip.ParsePrefix(raw)
if err != nil {
return netip.Prefix{}, fmt.Errorf("%q is not a CIDR", raw)
}
// Evaluate matches unmapped addresses, so a mapped CIDR could never match
// anything; refuse it rather than accept a rule that silently does nothing.
if cidr.Addr().Is4In6() {
return netip.Prefix{}, fmt.Errorf("%q is an IPv4-mapped IPv6 CIDR", raw)
}
if cidr != cidr.Masked() || cidr.String() != raw {
return netip.Prefix{}, fmt.Errorf("%q is not in canonical form (%s)", raw, cidr.Masked())
}
return cidr, nil
}
// NormalizeAuthority turns an :authority or Host value into a Destination:
// port split off, IP literal to IP, DNS name lowercased with one trailing dot
// removed and checked as a DNS-1123 subdomain. Anything else is an error, and
// the caller should deny.
func NormalizeAuthority(authority string) (Destination, error) {
if authority == "" {
return Destination{}, errors.New("authority is empty")
}
host := authority
var port uint16
if h, p, err := net.SplitHostPort(authority); err == nil {
n, err := strconv.ParseUint(p, 10, 16)
if err != nil || n == 0 {
return Destination{}, fmt.Errorf("authority %q has an invalid port", authority)
}
host, port = h, uint16(n)
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
if addr, err := netip.ParseAddr(host); err == nil {
if addr.Zone() != "" {
return Destination{}, fmt.Errorf("authority %q has an IPv6 zone", authority)
}
return Destination{IP: addr.Unmap(), Port: port}, nil
}
name := lowerASCII(strings.TrimSuffix(host, "."))
if !isHostname(name) {
return Destination{}, fmt.Errorf("authority %q is neither a DNS hostname nor an IP literal", authority)
}
return Destination{Hostname: name, Port: port}, nil
}
// isHostname reports whether name is a lowercase DNS-1123 subdomain whose last
// label is not all digits. RFC 1123 section 2.1 requires that, and it is what
// keeps a dotted-decimal address from passing as a name, including spellings
// like "01.2.3.4" that netip rejects but resolvers accept. IPv6 literals fail
// the subdomain check on their own.
func isHostname(name string) bool {
if len(content.IsDNS1123Subdomain(name)) != 0 {
return false
}
return !allDigits(name[strings.LastIndexByte(name, '.')+1:])
}
func allDigits(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// lowerASCII folds A-Z only. strings.ToLower would also fold non-ASCII onto
// ASCII letters (U+212A KELVIN SIGN onto "k") and let a non-ASCII spelling
// match a pattern for a different name.
func lowerASCII(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + ('a' - 'A')
}
}
return string(b)
}
+413
View File
@@ -0,0 +1,413 @@
// 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 egresspolicy
import (
"net/netip"
"testing"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)
func hostnameRule(patterns ...string) *ateapipb.EgressRule {
return &ateapipb.EgressRule{Hostnames: &ateapipb.HostnameRule{Patterns: patterns}}
}
func ipBlockRule(cidrs ...string) *ateapipb.EgressRule {
return &ateapipb.EgressRule{Cidrs: &ateapipb.CIDRRule{Cidrs: cidrs}}
}
func allRule() *ateapipb.EgressRule {
return &ateapipb.EgressRule{All: &emptypb.Empty{}}
}
func policy(rules ...*ateapipb.EgressRule) *ateapipb.EgressPolicy {
return &ateapipb.EgressPolicy{Rules: rules}
}
func mustCompile(t *testing.T, p *ateapipb.EgressPolicy) *Policy {
t.Helper()
compiled, errs := Compile(p)
if len(errs) != 0 {
t.Fatalf("Compile: %v", errs)
}
return compiled
}
func host(name string) Destination { return Destination{Hostname: name} }
func addr(ip string) Destination { return Destination{IP: netip.MustParseAddr(ip)} }
func TestParseHostnamePattern(t *testing.T) {
valid := []string{
"example.com",
"api.example.com",
"*.example.com",
"*.com",
"a-b.example.com",
"1.example.com",
"xn--bcher-kva.example",
}
for _, raw := range valid {
if _, err := ParseHostnamePattern(raw); err != nil {
t.Errorf("ParseHostnamePattern(%q) = %v, want ok", raw, err)
}
}
invalid := []string{
"",
"*",
"*.",
"API.EXAMPLE.COM",
"example.com.",
"192.0.2.1",
"01.2.3.4",
"example.123",
"2001:db8::1",
"example.com:443",
"https://example.com",
"api.*.example.com",
"**.example.com",
"*example.com",
"foo..example.com",
"-a.example.com",
"bücher.example",
}
for _, raw := range invalid {
if _, err := ParseHostnamePattern(raw); err == nil {
t.Errorf("ParseHostnamePattern(%q) = ok, want error", raw)
}
}
}
func TestHostnamePatternMatches(t *testing.T) {
tests := []struct {
pattern string
hostname string
want bool
}{
{"example.com", "example.com", true},
{"example.com", "api.example.com", false},
{"example.com", "example.org", false},
{"example.com", "notexample.com", false},
{"*.example.com", "api.example.com", true},
{"*.example.com", "example.com", false},
{"*.example.com", "a.b.example.com", false},
{"*.example.com", "notexample.com", false},
{"*.example.com", ".example.com", false},
{"*.com", "example.com", true},
{"*.com", "com", false},
}
for _, tc := range tests {
pattern, err := ParseHostnamePattern(tc.pattern)
if err != nil {
t.Fatalf("ParseHostnamePattern(%q): %v", tc.pattern, err)
}
if got := pattern.Matches(tc.hostname); got != tc.want {
t.Errorf("%q.Matches(%q) = %v, want %v", tc.pattern, tc.hostname, got, tc.want)
}
if pattern.String() != tc.pattern {
t.Errorf("String() = %q, want %q", pattern.String(), tc.pattern)
}
}
}
func TestParseCIDR(t *testing.T) {
valid := []string{
"192.0.2.0/24",
"192.0.2.1/32",
"0.0.0.0/0",
"2001:db8::/32",
"2001:db8::1/128",
"::/0",
}
for _, raw := range valid {
if _, err := ParseCIDR(raw); err != nil {
t.Errorf("ParseCIDR(%q) = %v, want ok", raw, err)
}
}
invalid := []string{
"",
"192.0.2.1",
"192.0.2.1/24",
"192.0.2.0/33",
"192.0.02.0/24",
"192.0.2.0/024",
"2001:DB8::/32",
"2001:0db8::/32",
"::ffff:192.0.2.0/120",
"fe80::%eth0/64",
"example.com/24",
}
for _, raw := range invalid {
if _, err := ParseCIDR(raw); err == nil {
t.Errorf("ParseCIDR(%q) = ok, want error", raw)
}
}
}
func TestNormalizeAuthority(t *testing.T) {
tests := []struct {
authority string
want Destination
wantErr bool
}{
{authority: "example.com", want: host("example.com")},
{authority: "example.com:8443", want: Destination{Hostname: "example.com", Port: 8443}},
{authority: "API.Example.COM", want: host("api.example.com")},
{authority: "example.com.", want: host("example.com")},
{authority: "example.com.:443", want: Destination{Hostname: "example.com", Port: 443}},
{authority: "192.0.2.1", want: addr("192.0.2.1")},
{authority: "192.0.2.1:80", want: Destination{IP: netip.MustParseAddr("192.0.2.1"), Port: 80}},
{authority: "[2001:db8::1]:443", want: Destination{IP: netip.MustParseAddr("2001:db8::1"), Port: 443}},
{authority: "[2001:db8::1]", want: addr("2001:db8::1")},
{authority: "2001:db8::1", want: addr("2001:db8::1")},
{authority: "[::ffff:192.0.2.1]:80", want: Destination{IP: netip.MustParseAddr("192.0.2.1"), Port: 80}},
{authority: "", wantErr: true},
{authority: "example.com..", wantErr: true},
{authority: "example.com:0", wantErr: true},
{authority: "example.com:99999", wantErr: true},
{authority: "example.com:https", wantErr: true},
{authority: "[fe80::1%25eth0]:443", wantErr: true},
{authority: "bücher.example", wantErr: true},
{authority: "exa mple.com", wantErr: true},
{authority: "http://example.com", wantErr: true},
{authority: "example.com/path", wantErr: true},
{authority: "_dmarc.example.com", wantErr: true},
}
for _, tc := range tests {
got, err := NormalizeAuthority(tc.authority)
if tc.wantErr {
if err == nil {
t.Errorf("NormalizeAuthority(%q) = %+v, want error", tc.authority, got)
}
continue
}
if err != nil {
t.Errorf("NormalizeAuthority(%q): %v", tc.authority, err)
continue
}
if got != tc.want {
t.Errorf("NormalizeAuthority(%q) = %+v, want %+v", tc.authority, got, tc.want)
}
}
}
func TestCompileReportsAndDropsInvalidEntries(t *testing.T) {
compiled, errs := Compile(policy(
hostnameRule("good.example.com", "BAD.example.com"),
ipBlockRule("192.0.2.0/24", "192.0.2.1/24"),
))
if len(errs) != 2 {
t.Fatalf("Compile errors = %v, want 2", errs)
}
if compiled.RuleCount() != 2 {
t.Errorf("RuleCount = %d, want 2", compiled.RuleCount())
}
if d := compiled.Evaluate(host("good.example.com")); !d.Allowed || d.RuleIndex != 0 {
t.Errorf("valid pattern of a partly invalid rule should still match, got %+v", d)
}
if d := compiled.Evaluate(host("bad.example.com")); d.Allowed {
t.Errorf("dropped pattern must not match, got %+v", d)
}
if d := compiled.Evaluate(addr("192.0.2.7")); !d.Allowed || d.RuleIndex != 1 {
t.Errorf("valid cidr of a partly invalid rule should still match, got %+v", d)
}
}
func TestEvaluate(t *testing.T) {
effects := &ateapipb.EgressRuleEffects{
InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{
Header: "authorization",
Prefix: "Bearer ",
CredentialUri: "substrate-secret://k8s/default/token",
}},
}
withEffects := &ateapipb.EgressRule{Hostnames: &ateapipb.HostnameRule{
Patterns: []string{"api.example.com"},
Effects: effects,
}}
tests := []struct {
name string
policy *ateapipb.EgressPolicy
dest Destination
want Decision
}{
{
name: "no rules denies",
policy: policy(),
dest: host("example.com"),
want: Decision{RuleIndex: -1},
},
{
name: "exact hostname",
policy: policy(hostnameRule("example.com")),
dest: host("example.com"),
want: Decision{Allowed: true, RuleIndex: 0, ByName: true},
},
{
name: "hostname rule does not match another name",
policy: policy(hostnameRule("example.com")),
dest: host("example.org"),
want: Decision{RuleIndex: -1},
},
{
name: "wildcard hostname",
policy: policy(hostnameRule("*.example.com")),
dest: host("api.example.com"),
want: Decision{Allowed: true, RuleIndex: 0, ByName: true},
},
{
name: "any pattern in the rule matches",
policy: policy(hostnameRule("other.example", "example.com")),
dest: host("example.com"),
want: Decision{Allowed: true, RuleIndex: 0, ByName: true},
},
{
name: "hostname rule never matches a destination with no hostname",
policy: policy(hostnameRule("*.example.com")),
dest: addr("192.0.2.1"),
want: Decision{RuleIndex: -1},
},
{
name: "cidr matches the address",
policy: policy(ipBlockRule("192.0.2.0/24")),
dest: addr("192.0.2.200"),
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "cidr does not match outside the prefix",
policy: policy(ipBlockRule("192.0.2.0/24")),
dest: addr("192.0.3.1"),
want: Decision{RuleIndex: -1},
},
{
name: "cidr never matches a destination with no address",
policy: policy(ipBlockRule("0.0.0.0/0")),
dest: host("example.com"),
want: Decision{RuleIndex: -1},
},
{
name: "ipv6 cidr",
policy: policy(ipBlockRule("2001:db8::/32")),
dest: addr("2001:db8:1::1"),
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "ipv4 cidr matches a mapped address",
policy: policy(ipBlockRule("192.0.2.0/24")),
dest: addr("::ffff:192.0.2.1"),
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "ipv4 cidr does not match ipv6",
policy: policy(ipBlockRule("0.0.0.0/0")),
dest: addr("2001:db8::1"),
want: Decision{RuleIndex: -1},
},
{
name: "all matches a hostname",
policy: policy(allRule()),
dest: host("example.com"),
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "all matches an address",
policy: policy(allRule()),
dest: addr("192.0.2.1"),
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "all matches an empty destination",
policy: policy(allRule()),
dest: Destination{},
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "first matching rule wins and carries its effects",
policy: policy(hostnameRule("other.example"), withEffects, allRule()),
dest: host("api.example.com"),
want: Decision{Allowed: true, RuleIndex: 1, Effects: effects, ByName: true},
},
{
name: "a later all rule does not lend effects to an earlier match",
policy: policy(hostnameRule("api.example.com"), withEffects),
dest: host("api.example.com"),
want: Decision{Allowed: true, RuleIndex: 0, ByName: true},
},
{
name: "hostname and dialed address are evaluated together",
policy: policy(hostnameRule("api.example.com"), ipBlockRule("10.0.0.0/8")),
dest: Destination{Hostname: "api.example.com", IP: netip.MustParseAddr("203.0.113.5"), Port: 443},
want: Decision{Allowed: true, RuleIndex: 0, ByName: true},
},
{
name: "address rule authorizes a request whose hostname no rule names",
policy: policy(hostnameRule("api.example.com"), ipBlockRule("10.0.0.0/8")),
dest: Destination{Hostname: "other.example", IP: netip.MustParseAddr("10.1.2.3"), Port: 443},
want: Decision{Allowed: true, RuleIndex: 1},
},
{
name: "an earlier address rule wins over a matching hostname rule",
policy: policy(ipBlockRule("10.0.0.0/8"), withEffects),
dest: Destination{Hostname: "api.example.com", IP: netip.MustParseAddr("10.1.2.3"), Port: 443},
want: Decision{Allowed: true, RuleIndex: 0},
},
{
name: "empty rule matches nothing",
policy: policy(&ateapipb.EgressRule{}, allRule()),
dest: host("example.com"),
want: Decision{Allowed: true, RuleIndex: 1},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := mustCompile(t, tc.policy).Evaluate(tc.dest); got != tc.want {
t.Errorf("Evaluate(%+v) = %+v, want %+v", tc.dest, got, tc.want)
}
})
}
}
func TestHasHostnameRules(t *testing.T) {
tests := []struct {
name string
policy *ateapipb.EgressPolicy
want bool
}{
{name: "no rules", policy: &ateapipb.EgressPolicy{}},
{name: "cidrs only", policy: &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{"10.0.0.0/8"}}}}}},
{name: "all only", policy: &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{{All: &emptypb.Empty{}}}}},
{name: "hostnames", policy: &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{{Hostnames: &ateapipb.HostnameRule{Patterns: []string{"api.example.com"}}}}}, want: true},
{name: "hostnames after a cidr", policy: &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{
{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{"10.0.0.0/8"}}},
{Hostnames: &ateapipb.HostnameRule{Patterns: []string{"*.example.com"}}},
}}, want: true},
// Every pattern was dropped at compile time, so the rule can match nothing.
{name: "hostnames that did not compile", policy: &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{{Hostnames: &ateapipb.HostnameRule{Patterns: []string{"not a hostname"}}}}}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
policy, _ := Compile(tc.policy)
if got := policy.HasHostnameRules(); got != tc.want {
t.Errorf("HasHostnameRules() = %v, want %v", got, tc.want)
}
})
}
}
+65
View File
@@ -0,0 +1,65 @@
// 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 resources
import (
"fmt"
"net/url"
"path"
"strings"
)
// ActorSPIFFETrustDomain is the trust domain of the SPIFFE ID an actor's
// certificate carries as its URI SAN.
//
// TODO(identity): Must be configurable per-install, so that each install can set it to a unique value.
const ActorSPIFFETrustDomain = "substrate-actor.local"
// ActorSPIFFEID returns "spiffe://substrate-actor.local/atespace/<atespace>/actor/<name>",
// which ateapi mints into the actor certificate's URI SAN.
func ActorSPIFFEID(r ActorRef) *url.URL {
return &url.URL{
Scheme: "spiffe",
Host: ActorSPIFFETrustDomain,
// TODO(identity): Prefix with "atunnel" to prevent
// confusion between atunnel and an actor pretending to be
// an atunnel.
Path: path.Join("atespace", r.Atespace, "actor", r.Name),
}
}
// ActorRefFromSPIFFEID parses an ID built by ActorSPIFFEID. Anything else is an
// error, so a URI SAN that merely resembles an actor ID never resolves to one.
func ActorRefFromSPIFFEID(id string) (ActorRef, error) {
u, err := url.Parse(id)
if err != nil {
return ActorRef{}, fmt.Errorf("invalid actor SPIFFE ID %q: %w", id, err)
}
if u.Scheme != "spiffe" || u.Host != ActorSPIFFETrustDomain || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return ActorRef{}, fmt.Errorf("invalid actor SPIFFE ID %q: must be spiffe://%s/atespace/<atespace>/actor/<name>", id, ActorSPIFFETrustDomain)
}
segments := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if len(segments) != 4 || segments[0] != "atespace" || segments[2] != "actor" {
return ActorRef{}, fmt.Errorf("invalid actor SPIFFE ID %q: must be spiffe://%s/atespace/<atespace>/actor/<name>", id, ActorSPIFFETrustDomain)
}
atespace, name := segments[1], segments[3]
if !IsValidResourceName(atespace) {
return ActorRef{}, fmt.Errorf("invalid actor SPIFFE ID %q: %q is not a valid atespace", id, atespace)
}
if !IsValidResourceName(name) {
return ActorRef{}, fmt.Errorf("invalid actor SPIFFE ID %q: %q is not a valid actor name", id, name)
}
return ActorRef{Atespace: atespace, Name: name}, nil
}
+57
View File
@@ -0,0 +1,57 @@
// 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 resources
import "testing"
func TestActorSPIFFEIDRoundTrip(t *testing.T) {
ref := ActorRef{Atespace: "team-a", Name: "agent-7"}
id := ActorSPIFFEID(ref)
if want := "spiffe://substrate-actor.local/atespace/team-a/actor/agent-7"; id.String() != want {
t.Fatalf("ActorSPIFFEID(%v) = %q, want %q", ref, id, want)
}
got, err := ActorRefFromSPIFFEID(id.String())
if err != nil {
t.Fatalf("ActorRefFromSPIFFEID(%q): %v", id, err)
}
if got != ref {
t.Errorf("ActorRefFromSPIFFEID(%q) = %v, want %v", id, got, ref)
}
}
func TestActorRefFromSPIFFEIDRejects(t *testing.T) {
for _, id := range []string{
"",
"spiffe://substrate-actor.local",
"spiffe://substrate-actor.local/atespace/team/actor",
"spiffe://substrate-actor.local/atespace/team/actor/agent/extra",
"spiffe://substrate-actor.local/namespace/team/actor/agent",
"spiffe://substrate-actor.local/atespace/team/pod/agent",
"spiffe://substrate-actor.local/atespace//actor/agent",
"spiffe://substrate-actor.local/atespace/Team/actor/agent",
"spiffe://substrate-actor.local/atespace/team/actor/agent?x=1",
"spiffe://substrate-actor.local/atespace/team/actor/agent#f",
"spiffe://cluster.local/atespace/team/actor/agent",
"spiffe://user@substrate-actor.local/atespace/team/actor/agent",
"https://substrate-actor.local/atespace/team/actor/agent",
"substrate-actor.local/atespace/team/actor/agent",
"spiffe://substrate-actor.local/atespace/team/actor/agent/",
"spiffe://substrate-actor.local/atespace/te%2Fam/actor/agent",
} {
if ref, err := ActorRefFromSPIFFEID(id); err == nil {
t.Errorf("ActorRefFromSPIFFEID(%q) = %v, want error", id, ref)
}
}
}