diff --git a/cmd/ateapi/internal/controlapi/actor.go b/cmd/ateapi/internal/controlapi/actor.go index 5393265d5..5bcff4070 100644 --- a/cmd/ateapi/internal/controlapi/actor.go +++ b/cmd/ateapi/internal/controlapi/actor.go @@ -23,7 +23,6 @@ import ( "fmt" "log/slog" "net/url" - "path" "slices" "time" @@ -672,17 +671,10 @@ func (s *RPCService) MintActorCertificate(ctx context.Context, req *ateapipb.Min switch req.GetPurpose() { case ateapipb.ActorCertificatePurpose_ACTOR_CERTIFICATE_PURPOSE_ATUNNEL: template = &x509.Certificate{ - URIs: []*url.URL{ - { - Scheme: "spiffe", - // 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()), - }, - }, + URIs: []*url.URL{resources.ActorSPIFFEID(resources.ActorRef{ + Atespace: dbActor.GetMetadata().GetAtespace(), + Name: dbActor.GetMetadata().GetName(), + })}, NotBefore: time.Now().Add(-5 * time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, diff --git a/cmd/ateapi/internal/controlapi/egress_policy.go b/cmd/ateapi/internal/controlapi/egress_policy.go index 098629294..5a96124d9 100644 --- a/cmd/ateapi/internal/controlapi/egress_policy.go +++ b/cmd/ateapi/internal/controlapi/egress_policy.go @@ -22,13 +22,13 @@ import ( "strings" "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/pkg/proto/ateapipb" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" "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/field" ) @@ -202,10 +202,14 @@ func ValidateCustom_EgressRuleEffects_InjectStaticHeaders(_ context.Context, _ o 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 { var errs field.ErrorList 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 } @@ -214,8 +218,7 @@ func validateHostnamePattern(raw string, p *field.Path) field.ErrorList { if raw == "" { return field.ErrorList{field.Required(p, "")} } - name := strings.TrimPrefix(raw, "*.") - if len(content.IsDNS1123Subdomain(name)) != 0 || len(validation.IsValidIP(p, name)) == 0 { + if _, err := egresspolicy.ParseHostnamePattern(raw); err != nil { return field.ErrorList{ field.Invalid(p, raw, "must be a DNS hostname, optionally with a complete leftmost-label wildcard"), } diff --git a/cmd/atenet/internal/router/egress/egress.go b/cmd/atenet/internal/router/egress/egress.go index 73c9b179a..9d692f77b 100644 --- a/cmd/atenet/internal/router/egress/egress.go +++ b/cmd/atenet/internal/router/egress/egress.go @@ -76,9 +76,8 @@ type Handler struct { actorIdentityRoots *x509.CertPool } -// New builds the egress handler. actorIdentityRoots is the same trust bundle -// the egress listener uses as its trusted_ca; see verifyActorCertificate for -// why the check is made again here. +// New builds the egress handler. actorIdentityRoots is the egress listener's +// trusted_ca; see verifyActorCertificate for why it is checked again here. func New(apiClient ateapipb.ControlClient, actorIdentityRoots *x509.CertPool) *Handler { return &Handler{apiClient: apiClient, actorIdentityRoots: actorIdentityRoots} } diff --git a/cmd/atenet/internal/router/extproc/attributes.go b/cmd/atenet/internal/router/extproc/attributes.go index 6d2925d0e..40e361536 100644 --- a/cmd/atenet/internal/router/extproc/attributes.go +++ b/cmd/atenet/internal/router/extproc/attributes.go @@ -37,15 +37,10 @@ const ( TargetActorFilterStateAttribute = "filter_state['" + TargetActorFilterStateKey + "']" ConnectAuthorityFilterStateAttribute = "filter_state['" + ConnectAuthorityFilterStateKey + "']" - // ActorIdentityFilterStateKey is the filter-state key holding the actor - // identity the egress gateway read from the peer certificate it verified - // against the actor-identity CA. Egress-only, and set and read entirely in - // manifests/ate-install/atenet-egress-with-sdsmint.yaml: the MITM access - // 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 holds the actor's SPIFFE ID + // (resources.ActorSPIFFEID), read from the peer certificate's URI SAN. + // The outer CONNECT chain sets it from %DOWNSTREAM_PEER_URI_SAN% and + // shares it with the inner legs, which have no certificate of their own. ActorIdentityFilterStateKey = "dev.ate.actor.identity" // directionAttribute carries the Direction outright, for dataplanes that diff --git a/internal/egresspolicy/egresspolicy.go b/internal/egresspolicy/egresspolicy.go new file mode 100644 index 000000000..92b4c7284 --- /dev/null +++ b/internal/egresspolicy/egresspolicy.go @@ -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) +} diff --git a/internal/egresspolicy/egresspolicy_test.go b/internal/egresspolicy/egresspolicy_test.go new file mode 100644 index 000000000..5e435ff63 --- /dev/null +++ b/internal/egresspolicy/egresspolicy_test.go @@ -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) + } + }) + } +} diff --git a/internal/resources/spiffe.go b/internal/resources/spiffe.go new file mode 100644 index 000000000..88e25fdc5 --- /dev/null +++ b/internal/resources/spiffe.go @@ -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//actor/", +// 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//actor/", 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//actor/", 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 +} diff --git a/internal/resources/spiffe_test.go b/internal/resources/spiffe_test.go new file mode 100644 index 000000000..ed3b6cc14 --- /dev/null +++ b/internal/resources/spiffe_test.go @@ -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) + } + } +}