diff --git a/cmd/atenet/internal/root.go b/cmd/atenet/internal/root.go index 1ab52398a2..fd7104a492 100644 --- a/cmd/atenet/internal/root.go +++ b/cmd/atenet/internal/root.go @@ -19,6 +19,7 @@ import ( "os" "github.com/agent-substrate/substrate/cmd/atenet/internal/router" + "github.com/agent-substrate/substrate/cmd/atenet/internal/router/egressinject" "github.com/agent-substrate/substrate/cmd/atenet/internal/sdsmint" "github.com/agent-substrate/substrate/internal/version" "github.com/spf13/cobra" @@ -42,4 +43,5 @@ func init() { rootCmd.AddCommand(router.NewRouterCmd()) rootCmd.AddCommand(NewDnsCmd()) rootCmd.AddCommand(sdsmint.NewSdsmintCmd()) + rootCmd.AddCommand(egressinject.NewCmd()) } diff --git a/cmd/atenet/internal/router/egressinject/cmd.go b/cmd/atenet/internal/router/egressinject/cmd.go new file mode 100644 index 0000000000..c5d7feee72 --- /dev/null +++ b/cmd/atenet/internal/router/egressinject/cmd.go @@ -0,0 +1,83 @@ +// 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 egressinject + +import ( + "time" + + "github.com/spf13/cobra" +) + +type config struct { + ProviderName string + ProviderAddress string + OnNoMatch string + ExtprocPort int + MetricsAddr string + LogLevel string + DrainGrace time.Duration + + // Serving TLS (the egress gateway dials this server with mTLS + SAN pin). + ServerCredBundle string + ClientCAFile string + + // Client TLS presented to the credential provider. + ProviderCAFile string + ProviderClientCert string + ProviderServerName string + + // ateapi Control connection: where each actor's egress policy is fetched. + AteapiAddr string + AteapiCAFile string + AteapiClientCert string + AteapiServerName string +} + +// NewCmd builds the `atenet egress-inject` subcommand: the ext_proc server the +// egress gateway's MITM leg calls to inject credentials into outbound requests. +func NewCmd() *cobra.Command { + var cfg config + + cmd := &cobra.Command{ + Use: "egress-inject", + Short: "ext_proc server that injects policy-selected credentials into egress requests on the MITM leg", + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd.Context(), cfg) + }, + } + + f := cmd.Flags() + f.StringVar(&cfg.ProviderName, "credential-provider-name", "substrate-secret://kubernetes.io", "the credential-provider this injector serves, as a substrate-secret:// class prefix (e.g. substrate-secret://kubernetes.io); a policy credential URI of any other class is refused (empty disables the check, dev only)") + f.StringVar(&cfg.ProviderAddress, "credential-provider-address", "", "address of the credential provider gRPC service; required") + f.StringVar(&cfg.OnNoMatch, "on-no-match", "allow", "what to do when no policy rule matches: allow (pass through) or deny (403)") + f.IntVar(&cfg.ExtprocPort, "port-extproc", 50051, "ext_proc gRPC listen port") + f.StringVar(&cfg.MetricsAddr, "metrics-address", ":9090", "Prometheus/health HTTP listen address") + f.StringVar(&cfg.LogLevel, "log-level", "info", "one of debug, info, warn, error") + f.DurationVar(&cfg.DrainGrace, "drain-grace", 5*time.Second, "how long to wait for in-flight RPCs on shutdown before a hard stop") + + f.StringVar(&cfg.ServerCredBundle, "server-cred-bundle", "", "credential bundle (PEM key+chain) presented for serving TLS to the gateway; empty serves plaintext (dev only)") + f.StringVar(&cfg.ClientCAFile, "client-ca-file", "", "CA the gateway's client certificate must chain to; empty accepts any client when TLS is on") + + f.StringVar(&cfg.ProviderCAFile, "provider-ca-file", "", "CA the credential provider's serving certificate must chain to; empty dials the provider plaintext (dev only)") + f.StringVar(&cfg.ProviderClientCert, "provider-client-cert", "", "credential bundle presented to the credential provider") + f.StringVar(&cfg.ProviderServerName, "provider-server-name", "", "expected SAN/SNI of the credential provider's serving certificate") + + f.StringVar(&cfg.AteapiAddr, "ateapi-address", "dns:///api.ate-system.svc:443", "gRPC dial target for the cluster ateapi Control instance, from which each actor's egress policy is fetched") + f.StringVar(&cfg.AteapiCAFile, "ateapi-ca-file", "", "CA the ateapi serving certificate must chain to; required") + f.StringVar(&cfg.AteapiClientCert, "ateapi-client-cert", "", "credential bundle presented to ateapi as the client certificate; required") + f.StringVar(&cfg.AteapiServerName, "ateapi-server-name", "", "expected SAN/SNI of the ateapi serving certificate") + + return cmd +} diff --git a/cmd/atenet/internal/router/egressinject/handler.go b/cmd/atenet/internal/router/egressinject/handler.go new file mode 100644 index 0000000000..811725db9b --- /dev/null +++ b/cmd/atenet/internal/router/egressinject/handler.go @@ -0,0 +1,264 @@ +// 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 egressinject implements the ext_proc handler that runs on the egress +// gateway's decrypted MITM leg: it fetches the requesting actor's egress policy +// from the ateapi control plane, matches each outbound request against it, and +// on a match that carries a credential injection fetches the credential from the +// credential provider and injects it as a request header. Actor identity comes +// from the CA-signed client cert the gateway verified on the CONNECT leg, +// relayed here as the ate.actor.identity filter-state attribute — never from a +// request header. +package egressinject + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "strings" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + envoy_type "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/cmd/atenet/internal/router/extproc" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +// actorIdentityAttribute is the CEL request attribute the MITM ext_proc filter +// forwards, carrying the actor's verified identity URI. It is the filter-state +// object ate.actor.identity the CONNECT leg set from the peer cert's URI SAN. +const actorIdentityAttribute = "filter_state['ate.actor.identity']" + +// schemeHeader is the HTTP/2 pseudo-header carrying the request scheme. On the +// MITM leg the TLS chain yields "https" and the cleartext chain "http"; the +// injector refuses to inject a credential over anything but https. +const schemeHeader = ":scheme" + +// NoMatchAction is what the handler does with a request no policy rule matches. +type NoMatchAction string + +const ( + // NoMatchAllow lets an unmatched request proceed unchanged (no injection). + NoMatchAllow NoMatchAction = "allow" + // NoMatchDeny rejects an unmatched request with 403. + NoMatchDeny NoMatchAction = "deny" +) + +// ParseNoMatchAction validates a --on-no-match flag value. +func ParseNoMatchAction(s string) (NoMatchAction, error) { + switch NoMatchAction(s) { + case NoMatchAllow: + return NoMatchAllow, nil + case NoMatchDeny: + return NoMatchDeny, nil + default: + return "", fmt.Errorf("invalid on-no-match %q (want allow or deny)", s) + } +} + +// Handler injects credentials into matched egress requests. +type Handler struct { + apiClient policyClient + provider credproviderpb.CredentialProviderClient + // providerClass is the credential-provider class this injector serves (e.g. + // "kubernetes.io", parsed from the substrate-secret:// prefix). A credential + // URI of any other class is refused. Empty disables the check (dev only). + providerClass string + onNoMatch NoMatchAction +} + +// New builds the injector handler. apiClient fetches an actor's egress policy +// from the ateapi control plane per request; providerClass is the credential +// provider class this injector serves, and a policy URI of another class fails +// closed. +func New(apiClient policyClient, provider credproviderpb.CredentialProviderClient, providerClass string, onNoMatch NoMatchAction) *Handler { + return &Handler{apiClient: apiClient, provider: provider, providerClass: providerClass, onNoMatch: onNoMatch} +} + +func (h *Handler) Direction() extproc.Direction { return extproc.DirectionEgressInject } + +// HandleRequestHeaders fetches the actor's egress policy, matches the request +// against it, and on a matched credential injection fetches the credential and +// returns a header mutation adding it. Unmatched requests follow onNoMatch; a +// policy or provider failure fails closed. +func (h *Handler) HandleRequestHeaders(ctx context.Context, md *extproc.RequestMetadata) (extproc.Result, error) { + identity := md.Attribute(actorIdentityAttribute) + host := hostFromAuthority(md.Host) + + atespace, actor, err := parseActorURI(identity) + if err != nil { + // No usable identity means no policy can be fetched. Treat it as a + // non-match so onNoMatch governs, but say why at the server. + slog.WarnContext(ctx, "egress-inject: unusable actor identity", slog.String("host", host), slog.Any("err", err)) + return h.noMatch(host) + } + + policy, err := h.apiClient.GetActorEgressPolicy(ctx, &ateapipb.GetActorEgressPolicyRequest{ + Actor: &ateapipb.ObjectRef{Atespace: atespace, Name: actor}, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + // The actor has no egress policy: nothing to inject. + slog.InfoContext(ctx, "egress-inject: actor has no egress policy", + slog.String("atespace", atespace), slog.String("actor", actor), slog.String("host", host)) + return h.noMatch(host) + } + // Fail closed: we cannot tell whether a credential was required, so the + // request must not go out potentially missing it. + slog.ErrorContext(ctx, "egress-inject: fetching egress policy failed", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.Any("err", err)) + return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_ServiceUnavailable, err, + "egress-inject: egress policy unavailable for %s", host) + } + + injections, matched := evaluate(policy, host) + if !matched { + slog.InfoContext(ctx, "egress-inject: no matching policy rule", + slog.String("atespace", atespace), slog.String("actor", actor), slog.String("host", host)) + return h.noMatch(host) + } + if len(injections) == 0 { + // A rule matched but injects nothing: the request is authorized, pass it + // through unchanged. + return extproc.Result{Target: host, Response: &extprocv3.HeadersResponse{Response: &extprocv3.CommonResponse{}}}, nil + } + + // Refuse to inject a credential over cleartext: on the cleartext MITM chain + // the request is re-originated without upstream TLS, so the secret would + // leave the pod in the clear. Test scheme != "https" (not == "http") so a + // missing or unknown scheme also fails closed. The egress-policy API has no + // per-rule cleartext opt-in, so this refusal is unconditional. + if scheme := strings.ToLower(md.Header(schemeHeader)); scheme != "https" { + slog.WarnContext(ctx, "egress-inject: refusing to inject credential over cleartext", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.String("scheme", scheme)) + return extproc.Result{Target: host}, extproc.NewReqError(envoy_type.StatusCode_Forbidden, + "egress-inject: refusing to inject a credential over cleartext to %s", host) + } + + setHeaders := make([]*corev3.HeaderValueOption, 0, len(injections)) + for _, inj := range injections { + if err := validateInjectHeader(inj.GetHeader()); err != nil { + slog.ErrorContext(ctx, "egress-inject: policy names an unusable header", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.String("header", inj.GetHeader()), slog.Any("err", err)) + return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_InternalServerError, err, + "egress-inject: policy for %s names an unusable header", host) + } + + // Confirm the credential URI targets the provider class this injector + // serves before dialing it: the configured provider fronts one class, so a + // URI of another class cannot be resolved here and must fail closed rather + // than be sent to the wrong provider. + if h.providerClass != "" { + class, err := credentialURIClass(inj.GetCredentialUri()) + if err != nil { + slog.ErrorContext(ctx, "egress-inject: policy names an unparseable credential URI", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), slog.Any("err", err)) + return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_InternalServerError, err, + "egress-inject: policy for %s names an unparseable credential URI", host) + } + if class != h.providerClass { + slog.ErrorContext(ctx, "egress-inject: credential URI targets an unserved provider class", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), + slog.String("class", class), slog.String("provider", h.providerClass)) + return extproc.Result{Target: host}, extproc.NewReqError(envoy_type.StatusCode_InternalServerError, + "egress-inject: credential URI for %s targets provider class %q, this injector serves %q", host, class, h.providerClass) + } + } + + resp, err := h.provider.RequestSecret(ctx, &credproviderpb.RequestSecretRequest{ + Uri: inj.GetCredentialUri(), + Context: &credproviderpb.SecretRequestContext{ + ActorIdentity: identity, + }, + }) + if err != nil { + // Fail closed: a credential we were told to inject but could not fetch + // must not let the request out without it. + slog.ErrorContext(ctx, "egress-inject: credential fetch failed", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), slog.Any("err", err)) + return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_ServiceUnavailable, err, + "egress-inject: credential unavailable for %s", host) + } + + secret, err := sanitizeSecret(resp.GetSecret()) + if err != nil { + // Fail closed: an empty or malformed credential must not go upstream as + // a bare "Bearer " nor as a header value Envoy would reject. + slog.ErrorContext(ctx, "egress-inject: unusable credential", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), slog.Any("err", err)) + return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_ServiceUnavailable, err, + "egress-inject: unusable credential for %s", host) + } + + // Overwrite any header the actor set itself, so a client cannot pre-seed + // a value that survives injection. + setHeaders = append(setHeaders, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{Key: inj.GetHeader(), RawValue: append([]byte(inj.GetPrefix()), secret...)}, + AppendAction: corev3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD, + }) + } + + slog.InfoContext(ctx, "egress-inject: injecting credential(s)", + slog.String("atespace", atespace), slog.String("actor", actor), + slog.String("host", host), slog.Int("headers", len(setHeaders))) + + return extproc.Result{ + Target: host, + Response: &extprocv3.HeadersResponse{ + Response: &extprocv3.CommonResponse{ + HeaderMutation: &extprocv3.HeaderMutation{SetHeaders: setHeaders}, + }, + }, + }, nil +} + +// sanitizeSecret prepares resolved secret bytes for use as (part of) an HTTP +// header value. It trims a trailing newline — a Kubernetes Secret created from a +// file commonly carries one — then rejects an empty secret or one containing any +// control character, which Envoy would reject as an invalid header value and, in +// the case of CR/LF, would allow header injection. +func sanitizeSecret(secret []byte) ([]byte, error) { + secret = bytes.TrimRight(secret, "\r\n") + if len(secret) == 0 { + return nil, fmt.Errorf("resolved credential is empty") + } + for _, b := range secret { + if b < 0x20 || b == 0x7f { + return nil, fmt.Errorf("resolved credential contains a control character") + } + } + return secret, nil +} + +// noMatch applies the configured no-match action. +func (h *Handler) noMatch(host string) (extproc.Result, error) { + if h.onNoMatch == NoMatchDeny { + return extproc.Result{Target: host}, extproc.NewReqError(envoy_type.StatusCode_Forbidden, + "egress-inject: no policy permits egress to %s", host) + } + // Allow: proceed unchanged. + return extproc.Result{Target: host, Response: &extprocv3.HeadersResponse{Response: &extprocv3.CommonResponse{}}}, nil +} diff --git a/cmd/atenet/internal/router/egressinject/handler_test.go b/cmd/atenet/internal/router/egressinject/handler_test.go new file mode 100644 index 0000000000..f5248911b3 --- /dev/null +++ b/cmd/atenet/internal/router/egressinject/handler_test.go @@ -0,0 +1,375 @@ +// 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 egressinject + +import ( + "context" + "errors" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + envoy_type "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/agent-substrate/substrate/cmd/atenet/internal/router/extproc" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +// fakeProvider is a stub CredentialProviderClient recording the last request. +type fakeProvider struct { + resp *credproviderpb.RequestSecretResponse + err error + gotReq *credproviderpb.RequestSecretRequest +} + +func (f *fakeProvider) RequestSecret(_ context.Context, req *credproviderpb.RequestSecretRequest, _ ...grpc.CallOption) (*credproviderpb.RequestSecretResponse, error) { + f.gotReq = req + return f.resp, f.err +} + +// fakePolicyClient is a stub ateapi policy client: it returns the policy keyed +// by the requested actor, NotFound when the actor is absent, or a fixed error. +type fakePolicyClient struct { + policies map[string]*ateapipb.EgressPolicy // keyed by "atespace/actor" + err error + gotReq *ateapipb.GetActorEgressPolicyRequest +} + +func (f *fakePolicyClient) GetActorEgressPolicy(_ context.Context, in *ateapipb.GetActorEgressPolicyRequest, _ ...grpc.CallOption) (*ateapipb.EgressPolicy, error) { + f.gotReq = in + if f.err != nil { + return nil, f.err + } + p, ok := f.policies[in.GetActor().GetAtespace()+"/"+in.GetActor().GetName()] + if !ok { + return nil, status.Error(codes.NotFound, "no egress policy") + } + return p, nil +} + +// sampleAPIClient serves the sample policy for team-a/my-actor. +func sampleAPIClient() *fakePolicyClient { + return &fakePolicyClient{policies: map[string]*ateapipb.EgressPolicy{ + "team-a/my-actor": sampleEgressPolicy(), + }} +} + +const testActorURI = "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor" + +// testProviderName is the provider class the handler tests configure; the sample +// policy's credential URIs are all of this class. +const testProviderName = "kubernetes.io" + +func metadataFor(t *testing.T, identity, host string) *extproc.RequestMetadata { + t.Helper() + // Default to https so the HTTPS path is what unscoped tests exercise. + return metadataForScheme(t, identity, host, "https") +} + +func metadataForScheme(t *testing.T, identity, host, scheme string) *extproc.RequestMetadata { + t.Helper() + st, err := structpb.NewStruct(map[string]any{actorIdentityAttribute: identity}) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + md := &extproc.RequestMetadata{ + Host: host, + Attributes: map[string]*structpb.Struct{"efp": st}, + } + if scheme != "" { + md.Headers = map[string]string{schemeHeader: scheme} + } + return md +} + +func TestHandleRequestHeadersInjects(t *testing.T) { + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + res, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com:443")) + if err != nil { + t.Fatalf("HandleRequestHeaders: %v", err) + } + + setHeaders := res.Response.GetResponse().GetHeaderMutation().GetSetHeaders() + if len(setHeaders) != 1 { + t.Fatalf("got %d header mutations, want 1", len(setHeaders)) + } + h0 := setHeaders[0] + if got := h0.GetHeader().GetKey(); got != "Authorization" { + t.Errorf("header key = %q, want Authorization", got) + } + if got := string(h0.GetHeader().GetRawValue()); got != "Bearer s3cr3t" { + t.Errorf("header value = %q, want %q", got, "Bearer s3cr3t") + } + if h0.GetAppendAction() != corev3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD { + t.Errorf("append action = %v, want OVERWRITE_IF_EXISTS_OR_ADD", h0.GetAppendAction()) + } + + // The provider was asked for the policy's URI with the attested context. + if got := provider.gotReq.GetUri(); got != "substrate-secret://kubernetes.io/team-secrets/ns1/example-api" { + t.Errorf("provider URI = %q", got) + } + if got := provider.gotReq.GetContext().GetActorIdentity(); got != testActorURI { + t.Errorf("actor identity = %q", got) + } +} + +func TestHandleRequestHeadersFetchesPolicyForActor(t *testing.T) { + api := sampleAPIClient() + h := New(api, &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t")}}, testProviderName, NoMatchAllow) + + if _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")); err != nil { + t.Fatalf("HandleRequestHeaders: %v", err) + } + if got := api.gotReq.GetActor(); got.GetAtespace() != "team-a" || got.GetName() != "my-actor" { + t.Errorf("GetActorEgressPolicy actor = %+v, want team-a/my-actor", got) + } +} + +func TestHandleRequestHeadersCleartextDenied(t *testing.T) { + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + // A matched rule carries an injection; the API has no cleartext opt-in, so + // an http request is always refused before the credential is fetched. + _, err := h.HandleRequestHeaders(context.Background(), metadataForScheme(t, testActorURI, "api.example.com", "http")) + assertReqErrCode(t, err, envoy_type.StatusCode_Forbidden) + if provider.gotReq != nil { + t.Error("provider was called for a cleartext request that should have been refused") + } +} + +func TestHandleRequestHeadersMissingSchemeDenied(t *testing.T) { + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + // An absent scheme must fail closed, not be treated as https. + _, err := h.HandleRequestHeaders(context.Background(), metadataForScheme(t, testActorURI, "api.example.com", "")) + assertReqErrCode(t, err, envoy_type.StatusCode_Forbidden) + if provider.gotReq != nil { + t.Error("provider was called for a request with no scheme") + } +} + +func TestHandleRequestHeadersHTTPSInjects(t *testing.T) { + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + res, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + if err != nil { + t.Fatalf("HandleRequestHeaders: %v", err) + } + if setHeaders := res.Response.GetResponse().GetHeaderMutation().GetSetHeaders(); len(setHeaders) != 1 { + t.Fatalf("got %d header mutations, want 1", len(setHeaders)) + } +} + +func TestHandleRequestHeadersMatchedNoInjectionPassesThrough(t *testing.T) { + // A rule matches but injects nothing: the request is authorized and passes + // through unchanged, over cleartext too (no secret leaves the pod). + api := &fakePolicyClient{policies: map[string]*ateapipb.EgressPolicy{ + "team-a/my-actor": {Rules: []*ateapipb.EgressRule{{ + Hostnames: &ateapipb.HostnameRule{Patterns: []string{"api.example.com"}}, + }}}, + }} + provider := &fakeProvider{} + h := New(api, provider, testProviderName, NoMatchDeny) + + res, err := h.HandleRequestHeaders(context.Background(), metadataForScheme(t, testActorURI, "api.example.com", "http")) + if err != nil { + t.Fatalf("HandleRequestHeaders: %v", err) + } + if muts := res.Response.GetResponse().GetHeaderMutation(); muts != nil { + t.Errorf("got header mutation %+v, want none", muts) + } + if provider.gotReq != nil { + t.Error("provider was called for a rule with no injection") + } +} + +func TestHandleRequestHeadersNoMatchAllow(t *testing.T) { + provider := &fakeProvider{} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + res, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "other.example.com")) + if err != nil { + t.Fatalf("HandleRequestHeaders: %v", err) + } + if muts := res.Response.GetResponse().GetHeaderMutation(); muts != nil { + t.Errorf("got header mutation %+v, want none", muts) + } + if provider.gotReq != nil { + t.Error("provider was called on a non-matching request") + } +} + +func TestHandleRequestHeadersNoMatchDeny(t *testing.T) { + h := New(sampleAPIClient(), &fakeProvider{}, testProviderName, NoMatchDeny) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "other.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_Forbidden) +} + +func TestHandleRequestHeadersNoPolicyForActorFollowsNoMatch(t *testing.T) { + provider := &fakeProvider{} + // The API has no policy for this actor (NotFound). Deny so it is observable. + h := New(&fakePolicyClient{}, provider, testProviderName, NoMatchDeny) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_Forbidden) + if provider.gotReq != nil { + t.Error("provider was called though the actor has no egress policy") + } +} + +func TestHandleRequestHeadersPolicyFetchFailsClosed(t *testing.T) { + provider := &fakeProvider{} + // A transport-level failure (not NotFound) must fail closed even under allow. + h := New(&fakePolicyClient{err: status.Error(codes.Unavailable, "ateapi down")}, provider, testProviderName, NoMatchAllow) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_ServiceUnavailable) + if provider.gotReq != nil { + t.Error("provider was called though the egress policy could not be fetched") + } +} + +func TestHandleRequestHeadersProviderFailsClosed(t *testing.T) { + provider := &fakeProvider{err: errors.New("provider down")} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_ServiceUnavailable) +} + +func TestHandleRequestHeadersEmptySecretFailsClosed(t *testing.T) { + // An empty credential must not go upstream as a bare "Bearer ". + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_ServiceUnavailable) +} + +func TestHandleRequestHeadersTrailingNewlineTrimmed(t *testing.T) { + // A Secret created from a file commonly carries a trailing newline; it must + // not end up in the header value (Envoy would reject it). + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t\n")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + res, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + if err != nil { + t.Fatalf("HandleRequestHeaders: %v", err) + } + setHeaders := res.Response.GetResponse().GetHeaderMutation().GetSetHeaders() + if len(setHeaders) != 1 || string(setHeaders[0].GetHeader().GetRawValue()) != "Bearer s3cr3t" { + t.Fatalf("header value = %q, want %q", string(setHeaders[0].GetHeader().GetRawValue()), "Bearer s3cr3t") + } +} + +func TestHandleRequestHeadersControlCharSecretFailsClosed(t *testing.T) { + // An embedded CR/LF would enable header injection; fail closed. + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3\r\ncr3t")}} + h := New(sampleAPIClient(), provider, testProviderName, NoMatchAllow) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_ServiceUnavailable) +} + +func TestHandleRequestHeadersWrongProviderClassFailsClosed(t *testing.T) { + // The policy's credential URI targets a provider class this injector does not + // serve; it must fail closed rather than dial the wrong provider. + provider := &fakeProvider{resp: &credproviderpb.RequestSecretResponse{Secret: []byte("s3cr3t")}} + api := &fakePolicyClient{policies: map[string]*ateapipb.EgressPolicy{ + "team-a/my-actor": {Rules: []*ateapipb.EgressRule{{ + Hostnames: &ateapipb.HostnameRule{ + Patterns: []string{"api.example.com"}, + Effects: &ateapipb.EgressRuleEffects{InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{ + Header: "Authorization", Prefix: "Bearer ", CredentialUri: "substrate-secret://vault.hashicorp.com/p/ns/s", + }}}, + }, + }}}, + }} + h := New(api, provider, testProviderName, NoMatchAllow) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, testActorURI, "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_InternalServerError) + if provider.gotReq != nil { + t.Error("provider was called for a URI of an unserved class") + } +} + +func TestSanitizeSecret(t *testing.T) { + tests := []struct { + name string + in []byte + want string + wantErr bool + }{ + {name: "plain", in: []byte("tok"), want: "tok"}, + {name: "trailing newline trimmed", in: []byte("tok\n"), want: "tok"}, + {name: "trailing crlf trimmed", in: []byte("tok\r\n"), want: "tok"}, + {name: "empty", in: []byte(""), wantErr: true}, + {name: "only newline", in: []byte("\n"), wantErr: true}, + {name: "embedded lf", in: []byte("to\nk"), wantErr: true}, + {name: "embedded cr", in: []byte("to\rk"), wantErr: true}, + {name: "embedded tab", in: []byte("to\tk"), wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := sanitizeSecret(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("sanitizeSecret(%q) = %q, want error", tc.in, got) + } + return + } + if err != nil { + t.Fatalf("sanitizeSecret(%q) unexpected error: %v", tc.in, err) + } + if string(got) != tc.want { + t.Errorf("sanitizeSecret(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestHandleRequestHeadersBadIdentityFollowsNoMatch(t *testing.T) { + provider := &fakeProvider{} + // Deny so an unusable identity is observably rejected rather than silently allowed. + h := New(sampleAPIClient(), provider, testProviderName, NoMatchDeny) + + _, err := h.HandleRequestHeaders(context.Background(), metadataFor(t, "not-a-spiffe-uri", "api.example.com")) + assertReqErrCode(t, err, envoy_type.StatusCode_Forbidden) + if provider.gotReq != nil { + t.Error("provider was called despite an unusable identity") + } +} + +func assertReqErrCode(t *testing.T, err error, want envoy_type.StatusCode) { + t.Helper() + var reqErr *extproc.ReqError + if !errors.As(err, &reqErr) { + t.Fatalf("error %v is not a *extproc.ReqError", err) + } + if reqErr.StatusCode != int(want) { + t.Errorf("status code = %d, want %d", reqErr.StatusCode, int(want)) + } +} diff --git a/cmd/atenet/internal/router/egressinject/policy.go b/cmd/atenet/internal/router/egressinject/policy.go new file mode 100644 index 0000000000..828ede1699 --- /dev/null +++ b/cmd/atenet/internal/router/egressinject/policy.go @@ -0,0 +1,136 @@ +// 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 egressinject + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + + "google.golang.org/grpc" + + "github.com/agent-substrate/substrate/internal/actorspiffe" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// credentialURIScheme is the only scheme a credential URI may carry. +const credentialURIScheme = "substrate-secret" + +// policyClient is the subset of ateapipb.ControlClient the injector calls: it +// fetches a single actor's egress policy on demand. *ateapipb.NewControlClient +// satisfies it; tests supply a fake. +type policyClient interface { + GetActorEgressPolicy(ctx context.Context, in *ateapipb.GetActorEgressPolicyRequest, opts ...grpc.CallOption) (*ateapipb.EgressPolicy, error) +} + +// evaluate applies an actor's egress policy to a request bound for host, +// mirroring the control-plane's semantics: rules are evaluated in order and the +// first matching rule wins — only its effects apply and evaluation stops even if +// a later rule would also match. It reports whether a rule matched (which +// authorizes the request) separately from the injections to apply, because a +// matching rule may carry no injection. +func evaluate(policy *ateapipb.EgressPolicy, host string) (inject []*ateapipb.CredentialHeaderInjection, matched bool) { + for _, r := range policy.GetRules() { + if ruleMatches(r, host) { + return r.GetHostnames().GetEffects().GetInjectStaticHeaders(), true + } + } + return nil, false +} + +// ruleMatches reports whether rule matches a request bound for host. The MITM +// leg matches on hostname, so an ip_blocks rule — which matches on the original +// destination IP, not available on this leg — never matches here. +func ruleMatches(rule *ateapipb.EgressRule, host string) bool { + switch { + case rule.GetAll() != nil: + return true + case rule.GetHostnames() != nil: + for _, p := range rule.GetHostnames().GetPatterns() { + if hostnameMatches(p, host) { + return true + } + } + } + return false +} + +// credentialURIClass returns the provider class of a substrate-secret:// URI — +// the URI host, e.g. "kubernetes.io" in +// substrate-secret://kubernetes.io///. The injector +// uses it to confirm a URI targets the provider class it is configured to serve. +func credentialURIClass(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parsing credential URI %q: %w", raw, err) + } + if u.Scheme != credentialURIScheme { + return "", fmt.Errorf("credential URI %q: scheme is %q, want %q", raw, u.Scheme, credentialURIScheme) + } + if u.Host == "" { + return "", fmt.Errorf("credential URI %q: missing provider class", raw) + } + return u.Host, nil +} + +// validateInjectHeader rejects header names the egress gateway's ext_proc +// mutation rules (disallow_system + disallow_is_error) would turn into a hard +// request failure: the HTTP/2 pseudo-headers and Host. The egress-policy API +// validates header format on write, so this is a defensive backstop. +func validateInjectHeader(name string) error { + if name == "" { + return fmt.Errorf("credential injection header is required") + } + if strings.HasPrefix(name, ":") || strings.EqualFold(name, "host") { + return fmt.Errorf("credential injection header %q is a system header the gateway forbids mutating", name) + } + return nil +} + +// hostnameMatches reports whether host matches pattern, following the egress +// policy API's HostnameRule semantics. A wildcard replaces the complete leftmost +// label: "*.example.com" matches exactly one non-empty label (e.g. +// "api.example.com") but not "example.com" or "a.b.example.com". Any other +// pattern is an exact, case-insensitive match. +func hostnameMatches(pattern, host string) bool { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + pattern = strings.ToLower(pattern) + if parent, ok := strings.CutPrefix(pattern, "*."); ok { + suffix := "." + parent + label, matched := strings.CutSuffix(host, suffix) + return matched && label != "" && !strings.Contains(label, ".") + } + return host == pattern +} + +// hostFromAuthority strips any port from an :authority/Host value, returning the +// bare hostname the policy matches on. +func hostFromAuthority(authority string) string { + if authority == "" { + return "" + } + if host, _, err := net.SplitHostPort(authority); err == nil { + return host + } + return authority +} + +// parseActorURI extracts the atespace and actor name from an actor SPIFFE URI of +// the form spiffe://substrate-actor.local/atespace//actor/. +func parseActorURI(raw string) (atespace, name string, err error) { + return actorspiffe.Parse(raw) +} diff --git a/cmd/atenet/internal/router/egressinject/policy_test.go b/cmd/atenet/internal/router/egressinject/policy_test.go new file mode 100644 index 0000000000..94c65e8f4f --- /dev/null +++ b/cmd/atenet/internal/router/egressinject/policy_test.go @@ -0,0 +1,217 @@ +// 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 egressinject + +import ( + "testing" + + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// sampleEgressPolicy is the actor egress policy the handler tests resolve for +// team-a/my-actor: a single hostname rule injecting a bearer token. +func sampleEgressPolicy() *ateapipb.EgressPolicy { + return &ateapipb.EgressPolicy{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "default"}, + Rules: []*ateapipb.EgressRule{{ + Hostnames: &ateapipb.HostnameRule{ + Patterns: []string{"api.example.com"}, + Effects: &ateapipb.EgressRuleEffects{ + InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{ + Header: "Authorization", + Prefix: "Bearer ", + CredentialUri: "substrate-secret://kubernetes.io/team-secrets/ns1/example-api", + }}, + }, + }, + }}, + } +} + +func TestEvaluate(t *testing.T) { + policy := sampleEgressPolicy() + + t.Run("match injects", func(t *testing.T) { + inj, matched := evaluate(policy, "api.example.com") + if !matched { + t.Fatal("evaluate did not match the hostname rule") + } + if len(inj) != 1 || inj[0].GetCredentialUri() != "substrate-secret://kubernetes.io/team-secrets/ns1/example-api" { + t.Errorf("evaluate returned %+v", inj) + } + }) + + t.Run("no rule matches", func(t *testing.T) { + if inj, matched := evaluate(policy, "other.example.com"); matched || inj != nil { + t.Errorf("evaluate(other host) = (%+v, %v), want (nil, false)", inj, matched) + } + }) + + t.Run("first matching rule wins", func(t *testing.T) { + // An earlier rule that matches but injects nothing shadows a later rule + // that would inject: only the first match's effects apply. + p := &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{ + {Hostnames: &ateapipb.HostnameRule{Patterns: []string{"api.example.com"}}}, + {Hostnames: &ateapipb.HostnameRule{ + Patterns: []string{"api.example.com"}, + Effects: &ateapipb.EgressRuleEffects{InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{ + Header: "Authorization", CredentialUri: "substrate-secret://kubernetes.io/p/ns/s", + }}}, + }}, + }} + inj, matched := evaluate(p, "api.example.com") + if !matched { + t.Fatal("evaluate did not match") + } + if len(inj) != 0 { + t.Errorf("first (injectionless) rule should win, got %+v", inj) + } + }) + + t.Run("all matcher matches every destination", func(t *testing.T) { + p := &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{{All: &emptypb.Empty{}}}} + if _, matched := evaluate(p, "anything.example.org"); !matched { + t.Error("an `all` rule should match every destination") + } + }) + + t.Run("ip_blocks rule never matches on the MITM leg", func(t *testing.T) { + p := &ateapipb.EgressPolicy{Rules: []*ateapipb.EgressRule{{IpBlocks: &ateapipb.IPBlockRule{Cidrs: []string{"0.0.0.0/0"}}}}} + if _, matched := evaluate(p, "api.example.com"); matched { + t.Error("an ip_blocks rule must not match hostname-based requests") + } + }) +} + +func TestHostnameMatches(t *testing.T) { + tests := []struct { + pattern string + host string + want bool + }{ + {"api.example.com", "api.example.com", true}, + {"api.example.com", "API.EXAMPLE.COM", true}, + {"api.example.com", "api.example.com.", true}, + {"api.example.com", "other.example.com", false}, + {"*.example.com", "api.example.com", true}, + // A wildcard replaces exactly one leftmost label per the API semantics. + {"*.example.com", "a.b.example.com", false}, + {"*.example.com", "example.com", false}, + {"*.example.com", "example.org", false}, + } + for _, tc := range tests { + if got := hostnameMatches(tc.pattern, tc.host); got != tc.want { + t.Errorf("hostnameMatches(%q, %q) = %v, want %v", tc.pattern, tc.host, got, tc.want) + } + } +} + +func TestHostFromAuthority(t *testing.T) { + tests := []struct{ in, want string }{ + {"api.example.com", "api.example.com"}, + {"api.example.com:443", "api.example.com"}, + {"", ""}, + } + for _, tc := range tests { + if got := hostFromAuthority(tc.in); got != tc.want { + t.Errorf("hostFromAuthority(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCredentialURIClass(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "full uri", raw: "substrate-secret://kubernetes.io/team-secrets/ns1/example-api", want: "kubernetes.io"}, + {name: "class prefix only", raw: "substrate-secret://kubernetes.io", want: "kubernetes.io"}, + {name: "other class", raw: "substrate-secret://vault.hashicorp.com/p/ns/s", want: "vault.hashicorp.com"}, + {name: "wrong scheme", raw: "https://kubernetes.io/x", wantErr: true}, + {name: "bare class no scheme", raw: "kubernetes.io", wantErr: true}, + {name: "empty", raw: "", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := credentialURIClass(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("credentialURIClass(%q) = %q, want error", tc.raw, got) + } + return + } + if err != nil { + t.Fatalf("credentialURIClass(%q) unexpected error: %v", tc.raw, err) + } + if got != tc.want { + t.Errorf("credentialURIClass(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestValidateInjectHeader(t *testing.T) { + for _, name := range []string{"", ":authority", ":scheme", "Host", "host"} { + if err := validateInjectHeader(name); err == nil { + t.Errorf("validateInjectHeader(%q) = nil, want error", name) + } + } + if err := validateInjectHeader("Authorization"); err != nil { + t.Errorf("validateInjectHeader(Authorization) = %v, want nil", err) + } +} + +func TestParseActorURI(t *testing.T) { + tests := []struct { + name string + uri string + wantAtespace string + wantActor string + wantErr bool + }{ + { + name: "valid", + uri: "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor", + wantAtespace: "team-a", + wantActor: "my-actor", + }, + {name: "wrong scheme", uri: "https://substrate-actor.local/atespace/team-a/actor/my-actor", wantErr: true}, + {name: "wrong trust domain", uri: "spiffe://other.local/atespace/team-a/actor/my-actor", wantErr: true}, + {name: "wrong structure", uri: "spiffe://substrate-actor.local/ns/team-a/actor/my-actor", wantErr: true}, + {name: "short path", uri: "spiffe://substrate-actor.local/atespace/team-a", wantErr: true}, + {name: "empty", uri: "", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + atespace, actor, err := parseActorURI(tc.uri) + if tc.wantErr { + if err == nil { + t.Fatalf("parseActorURI(%q) = (%q, %q), want error", tc.uri, atespace, actor) + } + return + } + if err != nil { + t.Fatalf("parseActorURI(%q) unexpected error: %v", tc.uri, err) + } + if atespace != tc.wantAtespace || actor != tc.wantActor { + t.Errorf("parseActorURI(%q) = (%q, %q), want (%q, %q)", tc.uri, atespace, actor, tc.wantAtespace, tc.wantActor) + } + }) + } +} diff --git a/cmd/atenet/internal/router/egressinject/run.go b/cmd/atenet/internal/router/egressinject/run.go new file mode 100644 index 0000000000..6b5dbcf728 --- /dev/null +++ b/cmd/atenet/internal/router/egressinject/run.go @@ -0,0 +1,234 @@ +// 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. + +// This file turns a parsed config into a running process: policy, provider +// client, ext_proc server, shutdown. The flags it reads are cmd.go's. + +package egressinject + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "syscall" + "time" + + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/agent-substrate/substrate/cmd/atenet/internal/router/extproc" + "github.com/agent-substrate/substrate/internal/ateapiauth" + "github.com/agent-substrate/substrate/internal/credbundle" + "github.com/agent-substrate/substrate/internal/serverboot" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +func run(ctx context.Context, cfg config) error { + serverboot.InitLogger() + if err := serverboot.SetLogLevel(cfg.LogLevel); err != nil { + return err + } + + if cfg.ProviderAddress == "" { + return errors.New("--credential-provider-address is required") + } + if cfg.AteapiAddr == "" { + return errors.New("--ateapi-address is required") + } + onNoMatch, err := ParseNoMatchAction(cfg.OnNoMatch) + if err != nil { + return err + } + + mp, err := serverboot.InitMetrics(ctx, extproc.ServiceName) + if err != nil { + return fmt.Errorf("init metrics: %w", err) + } + defer serverboot.ShutdownProvider("MeterProvider", mp.Shutdown) + + readiness := &serverboot.Readiness{} + go serverboot.StartMetricsServer(ctx, serverboot.MetricsServerOptions{ + Addr: cfg.MetricsAddr, + Readiness: readiness, + EnableHealthz: true, + }) + + providerConn, err := dialProvider(ctx, cfg) + if err != nil { + return fmt.Errorf("dial credential provider: %w", err) + } + defer providerConn.Close() + provider := credproviderpb.NewCredentialProviderClient(providerConn) + + apiConn, err := dialAteapi(cfg) + if err != nil { + return fmt.Errorf("dial ateapi: %w", err) + } + defer apiConn.Close() + apiClient := ateapipb.NewControlClient(apiConn) + + // The --credential-provider-name flag is a substrate-secret:// class prefix + // (e.g. substrate-secret://kubernetes.io); reduce it to the class the handler + // compares credential URIs against, failing fast on a malformed value. + var providerClass string + if cfg.ProviderName != "" { + providerClass, err = credentialURIClass(cfg.ProviderName) + if err != nil { + return fmt.Errorf("--credential-provider-name: %w", err) + } + } + + handler := New(apiClient, provider, providerClass, onNoMatch) + + routeDuration, err := extproc.NewRouteDurationHistogram() + if err != nil { + return fmt.Errorf("create route-duration histogram: %w", err) + } + srv := extproc.NewServerForDirection(cfg.ExtprocPort, routeDuration, extproc.DirectionEgressInject, handler) + + serverCreds, err := buildServerCreds(ctx, cfg) + if err != nil { + return fmt.Errorf("server credentials: %w", err) + } + var grpcOpts []grpc.ServerOption + if serverCreds != nil { + grpcOpts = append(grpcOpts, grpc.Creds(serverCreds)) + } + grpcServer := srv.NewGRPCServer(grpcOpts...) + + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.ExtprocPort)) + if err != nil { + return fmt.Errorf("listen on ext_proc port %d: %w", cfg.ExtprocPort, err) + } + defer lis.Close() + + shutdownCtx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer stop() + go func() { + <-shutdownCtx.Done() + slog.Info("shutting down") + readiness.MarkNotReady() + done := make(chan struct{}) + go func() { + grpcServer.GracefulStop() + close(done) + }() + select { + case <-done: + case <-time.After(cfg.DrainGrace): + slog.Warn("graceful shutdown timed out; forcing stop", slog.Duration("grace", cfg.DrainGrace)) + grpcServer.Stop() + } + }() + + slog.InfoContext(ctx, "egress-inject listening", + slog.Int("port", cfg.ExtprocPort), + slog.Bool("tls", serverCreds != nil), + slog.String("ateapi", cfg.AteapiAddr), + slog.String("provider", cfg.ProviderAddress), + slog.String("provider_name", cfg.ProviderName), + slog.String("on_no_match", string(onNoMatch)), + ) + if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return fmt.Errorf("serving: %w", err) + } + return nil +} + +// buildServerCreds composes the serving TLS the gateway dials with. Returns nil +// (plaintext) when no bundle is configured — dev only. +func buildServerCreds(ctx context.Context, cfg config) (credentials.TransportCredentials, error) { + if cfg.ServerCredBundle == "" { + slog.WarnContext(ctx, "no --server-cred-bundle set; serving plaintext (dev only)") + return nil, nil + } + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: credbundle.Loader(cfg.ServerCredBundle), + ClientAuth: tls.RequireAnyClientCert, + } + if cfg.ClientCAFile != "" { + pool, err := loadCAPool(cfg.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("--client-ca-file: %w", err) + } + tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert + tlsCfg.ClientCAs = pool + slog.InfoContext(ctx, "verifying gateway client certificates", slog.String("ca", cfg.ClientCAFile)) + } + return credentials.NewTLS(tlsCfg), nil +} + +// dialProvider dials the credential provider, with mTLS when configured and +// plaintext otherwise (dev only). +func dialProvider(ctx context.Context, cfg config) (*grpc.ClientConn, error) { + statsOpt := grpc.WithStatsHandler(otelgrpc.NewClientHandler()) + + if cfg.ProviderCAFile == "" { + slog.WarnContext(ctx, "no --provider-ca-file set; dialing credential provider plaintext (dev only)") + return grpc.NewClient(cfg.ProviderAddress, grpc.WithTransportCredentials(insecure.NewCredentials()), statsOpt) + } + + pool, err := loadCAPool(cfg.ProviderCAFile) + if err != nil { + return nil, fmt.Errorf("--provider-ca-file: %w", err) + } + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + RootCAs: pool, + ServerName: cfg.ProviderServerName, + } + if cfg.ProviderClientCert != "" { + tlsCfg.GetClientCertificate = credbundle.ClientLoader(cfg.ProviderClientCert) + } + return grpc.NewClient(cfg.ProviderAddress, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)), statsOpt) +} + +// dialAteapi dials the ateapi Control instance the injector fetches each +// actor's egress policy from, with the same mTLS the rest of atenet uses. The +// injector has no Kubernetes access, so it dials a DNS target rather than the +// k8s:/// resolver — no K8sClient is passed. +func dialAteapi(cfg config) (*grpc.ClientConn, error) { + dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{ + CAFile: cfg.AteapiCAFile, + ServerName: cfg.AteapiServerName, + ClientCredBundle: cfg.AteapiClientCert, + }) + if err != nil { + return nil, fmt.Errorf("building ateapi dial options: %w", err) + } + dialOpts = append(dialOpts, grpc.WithStatsHandler(otelgrpc.NewClientHandler())) + return grpc.NewClient(cfg.AteapiAddr, dialOpts...) +} + +func loadCAPool(path string) (*x509.CertPool, error) { + pem, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %q: %w", path, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no certificates in %q", path) + } + return pool, nil +} diff --git a/cmd/atenet/internal/router/extproc/dispatch.go b/cmd/atenet/internal/router/extproc/dispatch.go index 2da57f6def..8d0d550b94 100644 --- a/cmd/atenet/internal/router/extproc/dispatch.go +++ b/cmd/atenet/internal/router/extproc/dispatch.go @@ -28,6 +28,12 @@ const ( DirectionIngress Direction = "ingress" // DirectionEgress is outbound traffic tunneled out of an actor. DirectionEgress Direction = "egress" + // DirectionEgressInject is decrypted outbound traffic on the egress + // gateway's MITM leg, processed by the credential-injection handler. Unlike + // the other two it is never inferred from a request — the MITM ext_proc + // filter sends no filter-chain name — so a server that serves it pins the + // direction (see NewServerForDirection) rather than relying on directionOf. + DirectionEgressInject Direction = "egress-inject" ) const ( diff --git a/cmd/atenet/internal/router/extproc/extproc.go b/cmd/atenet/internal/router/extproc/extproc.go index 5f192fc757..bd43c01421 100644 --- a/cmd/atenet/internal/router/extproc/extproc.go +++ b/cmd/atenet/internal/router/extproc/extproc.go @@ -49,6 +49,11 @@ type Server struct { handlers Handlers recorder *QueryRecorder routeDuration metric.Float64Histogram + // forceDirection, when non-empty, pins every request to this direction + // instead of inferring it with directionOf. It is for a dataplane leg that + // carries no filter-chain name — the egress MITM leg — where the instance + // serves exactly one handler and inference has nothing to key on. + forceDirection Direction } // NewServer builds the ext_proc mux serving the given handlers. Passing a @@ -63,6 +68,15 @@ func NewServer(port int, routeDuration metric.Float64Histogram, handlers Handler } } +// NewServerForDirection builds a server that serves exactly one handler and +// pins every request to dir, skipping directionOf. Use it for the egress MITM +// leg, whose ext_proc filter sends no filter-chain name for directionOf to read. +func NewServerForDirection(port int, routeDuration metric.Float64Histogram, dir Direction, handler Handler) *Server { + s := NewServer(port, routeDuration, Handlers{dir: handler}) + s.forceDirection = dir + return s +} + // Queries returns the most recently processed requests, newest first, for the // /statusz page. func (s *Server) Queries() []RecordedQuery { @@ -77,10 +91,15 @@ func (s *Server) Recorder() *QueryRecorder { return s.recorder } // The caller owns its lifecycle: Run serves it, and the drain sequence in // drain.go stops it — gracefully first so in-flight streams (parked requests // above all) finish, forcefully past the drain timeout. -func (s *Server) NewGRPCServer() *grpc.Server { - grpcServer := grpc.NewServer( - grpc.StatsHandler(otelgrpc.NewServerHandler()), - ) +// +// Extra opts are appended after the default stats handler; the CONNECT-leg +// router passes none (Envoy dials it over localhost), while the egress MITM +// injector passes grpc.Creds for the mTLS the gateway dials it with. +func (s *Server) NewGRPCServer(opts ...grpc.ServerOption) *grpc.Server { + grpcServer := grpc.NewServer(append( + []grpc.ServerOption{grpc.StatsHandler(otelgrpc.NewServerHandler())}, + opts..., + )...) extprocv3.RegisterExternalProcessorServer(grpcServer, s) return grpcServer } @@ -138,8 +157,12 @@ func (s *Server) processRequestHeaders( // // Which handler runs is decided by the filter chain the dataplane says // accepted the request, never by anything in the request itself (see - // directionOf). - dir := directionOf(req) + // directionOf). A server pinned to one direction (the MITM leg, whose + // filter sends no filter-chain name) skips that inference. + dir := s.forceDirection + if dir == "" { + dir = directionOf(req) + } var res Result var err error diff --git a/cmd/credprovider/internal/kubeprovider/kubeprovider.go b/cmd/credprovider/internal/kubeprovider/kubeprovider.go new file mode 100644 index 0000000000..6f45ec87d4 --- /dev/null +++ b/cmd/credprovider/internal/kubeprovider/kubeprovider.go @@ -0,0 +1,198 @@ +// 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 kubeprovider implements the CredentialProvider plugin API backed by +// Kubernetes Secrets. It resolves substrate-secret:// URIs of the class +// "kubernetes.io" to a Secret value read straight from the Kubernetes API — so +// Substrate never stores the secret, it only brokers a read the provider is +// authorized to perform. +package kubeprovider + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "strings" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/internal/actorspiffe" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +// ProviderClass is the substrate-secret:// URI host this backend serves. +const ProviderClass = "kubernetes.io" + +// uriScheme is the only scheme a credential URI may carry. +const uriScheme = "substrate-secret" + +// SecretRef is a parsed substrate-secret:// URI for the kubernetes.io class. +// +// substrate-secret://kubernetes.io///[/] +type SecretRef struct { + // ProviderName is the registered provider instance name. It is opaque to + // this backend (a single kubernetes.io provider serves every name); it is + // retained only so it can be logged and, later, used to scope providers. + ProviderName string + Namespace string + Name string + // Key is the data key within the Secret, or "" when the URI omits it (the + // server then falls back to its configured default key). + Key string +} + +// ParseURI parses a substrate-secret:// URI of the kubernetes.io class. It +// rejects any other scheme or provider class so a misrouted URI fails loudly +// rather than reading the wrong store. +func ParseURI(raw string) (SecretRef, error) { + u, err := url.Parse(raw) + if err != nil { + return SecretRef{}, fmt.Errorf("parsing credential URI %q: %w", raw, err) + } + if u.Scheme != uriScheme { + return SecretRef{}, fmt.Errorf("credential URI %q: scheme is %q, want %q", raw, u.Scheme, uriScheme) + } + if u.Host != ProviderClass { + return SecretRef{}, fmt.Errorf("credential URI %q: provider class is %q, this provider serves %q", raw, u.Host, ProviderClass) + } + + segments := strings.Split(strings.Trim(u.Path, "/"), "/") + // // is the minimum; an optional 4th + // segment is the data key. + if len(segments) < 3 || len(segments) > 4 { + return SecretRef{}, fmt.Errorf("credential URI %q: want //[/], got %d path segments", raw, len(segments)) + } + for i, s := range segments { + if s == "" { + return SecretRef{}, fmt.Errorf("credential URI %q: empty path segment %d", raw, i) + } + } + + ref := SecretRef{ + ProviderName: segments[0], + Namespace: segments[1], + Name: segments[2], + } + if len(segments) == 4 { + ref.Key = segments[3] + } + return ref, nil +} + +// Server implements credproviderpb.CredentialProviderServer over the Kubernetes +// API. +type Server struct { + credproviderpb.UnimplementedCredentialProviderServer + + client kubernetes.Interface + // defaultKey is the Secret data key used when a URI omits one. When empty, a + // URI without a key resolves only if the Secret holds exactly one key. + defaultKey string + // nsAuth restricts which namespaces an atespace may resolve secrets from. + // Nil disables authorization (dev only): every URI namespace is allowed. + nsAuth *NamespaceAuthorizer +} + +// NewServer builds a Kubernetes-backed credential provider. defaultKey is used +// for URIs that omit a key; pass "" to require a single-key Secret in that case. +// nsAuth enforces the atespace→namespace policy; pass nil to disable +// authorization (dev only). +func NewServer(client kubernetes.Interface, defaultKey string, nsAuth *NamespaceAuthorizer) *Server { + return &Server{client: client, defaultKey: defaultKey, nsAuth: nsAuth} +} + +// RequestSecret resolves one substrate-secret:// URI to its Secret value. +func (s *Server) RequestSecret(ctx context.Context, req *credproviderpb.RequestSecretRequest) (*credproviderpb.RequestSecretResponse, error) { + ref, err := ParseURI(req.GetUri()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + if err := s.authorize(ctx, req.GetContext(), ref.Namespace); err != nil { + return nil, err + } + + slog.InfoContext(ctx, "resolving credential", + slog.String("provider", ref.ProviderName), + slog.String("namespace", ref.Namespace), + slog.String("secret", ref.Name), + slog.String("actor", req.GetContext().GetActorIdentity()), + ) + + secret, err := s.client.CoreV1().Secrets(ref.Namespace).Get(ctx, ref.Name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "secret %s/%s not found", ref.Namespace, ref.Name) + } + if k8serrors.IsForbidden(err) { + return nil, status.Errorf(codes.PermissionDenied, "not permitted to read secret %s/%s", ref.Namespace, ref.Name) + } + return nil, status.Errorf(codes.Unavailable, "reading secret %s/%s: %v", ref.Namespace, ref.Name, err) + } + + value, err := selectKey(secret.Data, ref.Key, s.defaultKey) + if err != nil { + return nil, status.Errorf(codes.NotFound, "secret %s/%s: %v", ref.Namespace, ref.Name, err) + } + return &credproviderpb.RequestSecretResponse{Secret: value}, nil +} + +// authorize enforces the atespace→namespace policy. When no authorizer is +// configured it is a no-op (dev only). Otherwise it derives the atespace from +// the attested actor identity and denies unless the URI's namespace is in that +// atespace's allowed list. +func (s *Server) authorize(ctx context.Context, reqCtx *credproviderpb.SecretRequestContext, namespace string) error { + if s.nsAuth == nil { + return nil + } + atespace, _, err := actorspiffe.Parse(reqCtx.GetActorIdentity()) + if err != nil { + slog.WarnContext(ctx, "credential request denied: unusable actor identity", slog.Any("err", err)) + return status.Error(codes.PermissionDenied, "actor identity is required and must be a valid actor SPIFFE URI") + } + if !s.nsAuth.Allowed(atespace, namespace) { + slog.WarnContext(ctx, "credential request denied: atespace not permitted for namespace", + slog.String("atespace", atespace), slog.String("namespace", namespace)) + return status.Errorf(codes.PermissionDenied, "atespace %q is not permitted to resolve secrets in namespace %q", atespace, namespace) + } + return nil +} + +// selectKey resolves which Secret data entry to return: the URI's explicit key, +// else the configured default key, else the sole key of a single-key Secret. +func selectKey(data map[string][]byte, uriKey, defaultKey string) ([]byte, error) { + key := uriKey + if key == "" { + key = defaultKey + } + if key == "" { + if len(data) != 1 { + return nil, fmt.Errorf("no key given and the secret has %d keys; specify one in the URI or configure a default", len(data)) + } + for _, v := range data { + return v, nil + } + } + v, ok := data[key] + if !ok { + return nil, fmt.Errorf("key %q not present", key) + } + return v, nil +} diff --git a/cmd/credprovider/internal/kubeprovider/kubeprovider_test.go b/cmd/credprovider/internal/kubeprovider/kubeprovider_test.go new file mode 100644 index 0000000000..d649b82c95 --- /dev/null +++ b/cmd/credprovider/internal/kubeprovider/kubeprovider_test.go @@ -0,0 +1,271 @@ +// 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 kubeprovider + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/internal/proto/nsauthzpb" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +func TestParseURI(t *testing.T) { + tests := []struct { + name string + uri string + want SecretRef + wantErr bool + }{ + { + name: "with key", + uri: "substrate-secret://kubernetes.io/team-secrets/ns1/example-api/token", + want: SecretRef{ProviderName: "team-secrets", Namespace: "ns1", Name: "example-api", Key: "token"}, + }, + { + name: "without key", + uri: "substrate-secret://kubernetes.io/team-secrets/ns1/example-api", + want: SecretRef{ProviderName: "team-secrets", Namespace: "ns1", Name: "example-api"}, + }, + {name: "wrong scheme", uri: "https://kubernetes.io/team-secrets/ns1/example-api", wantErr: true}, + {name: "wrong provider class", uri: "substrate-secret://vault.io/team-secrets/ns1/example-api", wantErr: true}, + {name: "too few segments", uri: "substrate-secret://kubernetes.io/team-secrets/ns1", wantErr: true}, + {name: "too many segments", uri: "substrate-secret://kubernetes.io/a/b/c/d/e", wantErr: true}, + {name: "unparseable", uri: "://://", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseURI(tc.uri) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseURI(%q) = %+v, want error", tc.uri, got) + } + return + } + if err != nil { + t.Fatalf("ParseURI(%q) unexpected error: %v", tc.uri, err) + } + if got != tc.want { + t.Errorf("ParseURI(%q) = %+v, want %+v", tc.uri, got, tc.want) + } + }) + } +} + +func TestNewNamespaceAuthorizer(t *testing.T) { + authz, err := NewNamespaceAuthorizer(&nsauthzpb.NamespaceAuthorizationFile{ + Policy: []*nsauthzpb.AtespaceNamespacePolicy{ + {Atespace: "team-a", AllowedNamespaces: []string{"ns1", "shared"}}, + {Atespace: "team-b", AllowedNamespaces: []string{"ns2"}}, + }, + }) + if err != nil { + t.Fatalf("NewNamespaceAuthorizer: %v", err) + } + tests := []struct { + atespace, namespace string + want bool + }{ + {"team-a", "ns1", true}, + {"team-a", "shared", true}, + {"team-a", "ns2", false}, // namespace not in team-a's list + {"team-b", "ns2", true}, // team-b's own namespace + {"team-c", "ns1", false}, // atespace absent -> default deny + {"team-a", "", false}, // empty namespace + } + for _, tc := range tests { + if got := authz.Allowed(tc.atespace, tc.namespace); got != tc.want { + t.Errorf("Allowed(%q, %q) = %v, want %v", tc.atespace, tc.namespace, got, tc.want) + } + } + + // An empty file denies everything. + empty, err := NewNamespaceAuthorizer(&nsauthzpb.NamespaceAuthorizationFile{}) + if err != nil { + t.Fatalf("NewNamespaceAuthorizer(empty): %v", err) + } + if empty.Allowed("team-a", "ns1") { + t.Error("empty authorizer allowed team-a/ns1, want deny") + } + + // A policy without an atespace is rejected. + if _, err := NewNamespaceAuthorizer(&nsauthzpb.NamespaceAuthorizationFile{ + Policy: []*nsauthzpb.AtespaceNamespacePolicy{{AllowedNamespaces: []string{"ns1"}}}, + }); err == nil { + t.Error("NewNamespaceAuthorizer accepted a policy with no atespace, want error") + } +} + +func TestRequestSecretAuthorization(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "example-api", Namespace: "ns1"}, + Data: map[string][]byte{"token": []byte("s3cr3t")}, + } + authz, err := NewNamespaceAuthorizer(&nsauthzpb.NamespaceAuthorizationFile{ + Policy: []*nsauthzpb.AtespaceNamespacePolicy{{Atespace: "team-a", AllowedNamespaces: []string{"ns1"}}}, + }) + if err != nil { + t.Fatalf("NewNamespaceAuthorizer: %v", err) + } + const teamAURI = "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor" + const teamBURI = "spiffe://substrate-actor.local/atespace/team-b/actor/my-actor" + + tests := []struct { + name string + ctx *credproviderpb.SecretRequestContext + uri string + wantCode codes.Code + }{ + { + name: "allowed", + ctx: &credproviderpb.SecretRequestContext{ActorIdentity: teamAURI}, + uri: "substrate-secret://kubernetes.io/p/ns1/example-api/token", + }, + { + name: "namespace not permitted", + ctx: &credproviderpb.SecretRequestContext{ActorIdentity: teamAURI}, + uri: "substrate-secret://kubernetes.io/p/ns2/example-api/token", + wantCode: codes.PermissionDenied, + }, + { + name: "unknown atespace", + ctx: &credproviderpb.SecretRequestContext{ActorIdentity: teamBURI}, + uri: "substrate-secret://kubernetes.io/p/ns1/example-api/token", + wantCode: codes.PermissionDenied, + }, + { + name: "garbage identity", + ctx: &credproviderpb.SecretRequestContext{ActorIdentity: "not-a-spiffe-uri"}, + uri: "substrate-secret://kubernetes.io/p/ns1/example-api/token", + wantCode: codes.PermissionDenied, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := NewServer(fake.NewSimpleClientset(secret), "", authz) + resp, err := srv.RequestSecret(context.Background(), &credproviderpb.RequestSecretRequest{Uri: tc.uri, Context: tc.ctx}) + if tc.wantCode != codes.OK { + if status.Code(err) != tc.wantCode { + t.Fatalf("code = %v, want %v (err=%v)", status.Code(err), tc.wantCode, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := string(resp.GetSecret()); got != "s3cr3t" { + t.Errorf("secret = %q, want s3cr3t", got) + } + }) + } + + // With no authorizer configured, enforcement is bypassed entirely. + t.Run("nil authorizer bypasses", func(t *testing.T) { + srv := NewServer(fake.NewSimpleClientset(secret), "", nil) + if _, err := srv.RequestSecret(context.Background(), &credproviderpb.RequestSecretRequest{ + Uri: "substrate-secret://kubernetes.io/p/ns1/example-api/token", + Context: &credproviderpb.SecretRequestContext{ActorIdentity: "not-a-spiffe-uri"}, + }); err != nil { + t.Fatalf("nil authorizer should not enforce, got %v", err) + } + }) +} + +func TestRequestSecret(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "example-api", Namespace: "ns1"}, + Data: map[string][]byte{ + "token": []byte("s3cr3t"), + }, + } + multiKey := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "multi", Namespace: "ns1"}, + Data: map[string][]byte{ + "a": []byte("aa"), + "b": []byte("bb"), + }, + } + + tests := []struct { + name string + defaultKey string + uri string + want string + wantCode codes.Code + }{ + { + name: "explicit key", + uri: "substrate-secret://kubernetes.io/p/ns1/example-api/token", + want: "s3cr3t", + }, + { + name: "default key", + defaultKey: "token", + uri: "substrate-secret://kubernetes.io/p/ns1/example-api", + want: "s3cr3t", + }, + { + name: "single-key fallback", + uri: "substrate-secret://kubernetes.io/p/ns1/example-api", + want: "s3cr3t", + }, + { + name: "no key, multiple keys", + uri: "substrate-secret://kubernetes.io/p/ns1/multi", + wantCode: codes.NotFound, + }, + { + name: "missing key", + uri: "substrate-secret://kubernetes.io/p/ns1/example-api/nope", + wantCode: codes.NotFound, + }, + { + name: "secret not found", + uri: "substrate-secret://kubernetes.io/p/ns1/absent/token", + wantCode: codes.NotFound, + }, + { + name: "bad uri", + uri: "substrate-secret://vault.io/p/ns1/example-api", + wantCode: codes.InvalidArgument, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := fake.NewSimpleClientset(secret, multiKey) + srv := NewServer(client, tc.defaultKey, nil) + resp, err := srv.RequestSecret(context.Background(), &credproviderpb.RequestSecretRequest{Uri: tc.uri}) + if tc.wantCode != codes.OK { + if status.Code(err) != tc.wantCode { + t.Fatalf("RequestSecret(%q) code = %v, want %v (err=%v)", tc.uri, status.Code(err), tc.wantCode, err) + } + return + } + if err != nil { + t.Fatalf("RequestSecret(%q) unexpected error: %v", tc.uri, err) + } + if got := string(resp.GetSecret()); got != tc.want { + t.Errorf("RequestSecret(%q) = %q, want %q", tc.uri, got, tc.want) + } + }) + } +} diff --git a/cmd/credprovider/internal/kubeprovider/nsauthz.go b/cmd/credprovider/internal/kubeprovider/nsauthz.go new file mode 100644 index 0000000000..80acd89e29 --- /dev/null +++ b/cmd/credprovider/internal/kubeprovider/nsauthz.go @@ -0,0 +1,79 @@ +// 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 kubeprovider + +import ( + "fmt" + "os" + + "google.golang.org/protobuf/encoding/prototext" + + "github.com/agent-substrate/substrate/internal/proto/nsauthzpb" +) + +// NamespaceAuthorizer decides whether an atespace may resolve secrets in a given +// Kubernetes namespace. It is default-deny: an atespace absent from the mapping +// can resolve nothing. +type NamespaceAuthorizer struct { + // allowed maps atespace -> set of permitted namespaces. + allowed map[string]map[string]struct{} +} + +// LoadNamespaceAuthorizer reads a textproto NamespaceAuthorizationFile from path +// and builds an authorizer, so a malformed file fails startup rather than the +// first request. +func LoadNamespaceAuthorizer(path string) (*NamespaceAuthorizer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading namespace policy file %q: %w", path, err) + } + var file nsauthzpb.NamespaceAuthorizationFile + if err := prototext.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("parsing namespace policy file %q: %w", path, err) + } + return NewNamespaceAuthorizer(&file) +} + +// NewNamespaceAuthorizer builds an authorizer over an already-parsed file, +// validating that each policy names an atespace. +func NewNamespaceAuthorizer(file *nsauthzpb.NamespaceAuthorizationFile) (*NamespaceAuthorizer, error) { + allowed := make(map[string]map[string]struct{}) + for i, p := range file.GetPolicy() { + if p.GetAtespace() == "" { + return nil, fmt.Errorf("namespace policy %d: atespace is required", i) + } + set := allowed[p.GetAtespace()] + if set == nil { + set = make(map[string]struct{}) + allowed[p.GetAtespace()] = set + } + for _, ns := range p.GetAllowedNamespaces() { + set[ns] = struct{}{} + } + } + return &NamespaceAuthorizer{allowed: allowed}, nil +} + +// Allowed reports whether atespace may resolve secrets in namespace. Default +// deny: an atespace absent from the mapping, or a namespace not in its list, is +// refused. +func (a *NamespaceAuthorizer) Allowed(atespace, namespace string) bool { + set, ok := a.allowed[atespace] + if !ok { + return false + } + _, ok = set[namespace] + return ok +} diff --git a/cmd/credprovider/main.go b/cmd/credprovider/main.go new file mode 100644 index 0000000000..746055deed --- /dev/null +++ b/cmd/credprovider/main.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. + +// Command credprovider is a POC credential-provider plugin: a gRPC service that +// resolves substrate-secret:// URIs of the kubernetes.io class to Kubernetes +// Secret values. It is the only component in the egress credential-injection +// path with Kubernetes access; the egress gateway and its injector never read +// Secrets directly. +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/pflag" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/reflection" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/agent-substrate/substrate/cmd/credprovider/internal/kubeprovider" + "github.com/agent-substrate/substrate/internal/credbundle" + "github.com/agent-substrate/substrate/internal/serverboot" + "github.com/agent-substrate/substrate/internal/version" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +const serviceName = "credprovider" + +var ( + listenAddr = pflag.String("listen-address", ":50051", "gRPC listen address") + metricsAddr = pflag.String("metrics-address", ":9090", "Prometheus/health HTTP listen address") + serverBundle = pflag.String("server-cred-bundle", "", "credential bundle (PEM key+chain) presented for serving TLS; empty serves plaintext (dev only)") + clientCAFile = pflag.String("client-ca-file", "", "CA bundle that caller (injector) client certificates must chain to; empty accepts any client when TLS is on") + defaultKey = pflag.String("default-secret-key", "", "Secret data key used when a credential URI omits one; empty requires a single-key Secret") + nsPolicyFile = pflag.String("namespace-policy-file", "", "path to the atespace→namespace authorization textproto; empty disables authorization (dev only)") + logLevel = pflag.String("log-level", "info", "one of debug, info, warn, error") + drainGrace = pflag.Duration("drain-grace", 5*time.Second, "how long to wait for in-flight RPCs on shutdown before a hard stop") +) + +func main() { + pflag.Parse() + + ctx := context.Background() + serverboot.InitLogger() + if err := serverboot.SetLogLevel(*logLevel); err != nil { + serverboot.Fatal(ctx, "invalid --log-level", err) + } + + slog.InfoContext(ctx, "starting credprovider", slog.String("version", version.String())) + + if err := run(ctx); err != nil { + serverboot.Fatal(ctx, "credprovider exited with error", err) + } +} + +func run(ctx context.Context) error { + mp, err := serverboot.InitMetrics(ctx, serviceName) + if err != nil { + return fmt.Errorf("init metrics: %w", err) + } + defer serverboot.ShutdownProvider("MeterProvider", mp.Shutdown) + + readiness := &serverboot.Readiness{} + go serverboot.StartMetricsServer(ctx, serverboot.MetricsServerOptions{ + Addr: *metricsAddr, + Readiness: readiness, + EnableHealthz: true, + }) + + client, err := newKubeClient() + if err != nil { + return fmt.Errorf("kubernetes client: %w", err) + } + + var nsAuth *kubeprovider.NamespaceAuthorizer + if *nsPolicyFile != "" { + nsAuth, err = kubeprovider.LoadNamespaceAuthorizer(*nsPolicyFile) + if err != nil { + return fmt.Errorf("namespace policy: %w", err) + } + slog.InfoContext(ctx, "loaded namespace authorization policy", slog.String("file", *nsPolicyFile)) + } else { + slog.WarnContext(ctx, "no --namespace-policy-file set; authorization disabled, any atespace may resolve any namespace (dev only)") + } + + creds, err := buildServerCreds(ctx) + if err != nil { + return fmt.Errorf("server credentials: %w", err) + } + + opts := []grpc.ServerOption{grpc.StatsHandler(otelgrpc.NewServerHandler())} + if creds != nil { + opts = append(opts, grpc.Creds(creds)) + } + srv := grpc.NewServer(opts...) + reflection.Register(srv) + credproviderpb.RegisterCredentialProviderServer(srv, kubeprovider.NewServer(client, *defaultKey, nsAuth)) + + lis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", *listenAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", *listenAddr, err) + } + + shutdownCtx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer stop() + go func() { + <-shutdownCtx.Done() + slog.Info("shutting down") + readiness.MarkNotReady() + done := make(chan struct{}) + go func() { + srv.GracefulStop() + close(done) + }() + select { + case <-done: + case <-time.After(*drainGrace): + slog.Warn("graceful shutdown timed out; forcing stop", slog.Duration("grace", *drainGrace)) + srv.Stop() + } + }() + + slog.InfoContext(ctx, "credprovider listening", slog.String("address", lis.Addr().String()), slog.Bool("tls", creds != nil)) + if err := srv.Serve(lis); err != nil && err != grpc.ErrServerStopped { + return fmt.Errorf("serving: %w", err) + } + return nil +} + +func newKubeClient() (kubernetes.Interface, error) { + cfg, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("in-cluster config: %w", err) + } + return kubernetes.NewForConfig(cfg) +} + +// buildServerCreds composes serving TLS from the credential bundle, verifying +// caller client certificates against the configured CA. Returns nil (plaintext) +// when no bundle is configured — a dev-only path; in-cluster the injector dials +// with mTLS and SAN pinning. +func buildServerCreds(ctx context.Context) (credentials.TransportCredentials, error) { + if *serverBundle == "" { + slog.WarnContext(ctx, "no --server-cred-bundle set; serving plaintext (dev only)") + return nil, nil + } + + cfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: credbundle.Loader(*serverBundle), + ClientAuth: tls.RequireAnyClientCert, + } + if *clientCAFile != "" { + ca, err := os.ReadFile(*clientCAFile) + if err != nil { + return nil, fmt.Errorf("read --client-ca-file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("no certificates in --client-ca-file %q", *clientCAFile) + } + cfg.ClientAuth = tls.RequireAndVerifyClientCert + cfg.ClientCAs = pool + slog.InfoContext(ctx, "verifying caller client certificates", slog.String("ca", *clientCAFile)) + } + return credentials.NewTLS(cfg), nil +} diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 43f4ee41a4..d72e74809c 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -83,6 +83,17 @@ function usage() { echo " --experimental-additional-egress-extproc-service NS/SVC:PORT" echo " Run an additional ext_proc authorization filter, served by that Service." echo " Requires --experimental-use-sdsmint. (experimental)" + echo " --experimental-egress-credential-injection" + echo " Deploy the egress credential-injection stack (credprovider, injector," + echo " namespace policy, sample secret) and wire the egress gateway to it." + echo " Implies --experimental-use-sdsmint; requires --atenet-router=envoy. (experimental)" + echo " --credential-provider-name NAME Provider the injector serves, as a substrate-secret:// class prefix," + echo " e.g. substrate-secret://kubernetes.io (default substrate-secret://kubernetes.io)." + echo " Only meaningful with --experimental-egress-credential-injection. (experimental)" + echo " --credential-provider-address HOST:PORT" + echo " Address the injector dials the credential provider at" + echo " (default credprovider.ate-system.svc:50051)." + echo " Only meaningful with --experimental-egress-credential-injection. (experimental)" echo "" echo "Infrastructure components:" echo "" @@ -705,6 +716,55 @@ deploy_atenet() { run_kubectl rollout status deployment/dns -n ate-system --timeout="$(rollout_timeout)" } +# render_atenet_egress_inject_manifest echoes the injector manifest with its +# --credential-provider-name and --credential-provider-address arguments set to +# the configured values. The manifest ships the in-cluster defaults; +# --credential-provider-name / --credential-provider-address override them (e.g. +# to point at a different provider class or address). Substituting the literal +# defaults keeps the manifest applyable by hand with no placeholder. +render_atenet_egress_inject_manifest() { + local name="${ATE_CREDENTIAL_PROVIDER_NAME:-substrate-secret://kubernetes.io}" + local addr="${ATE_CREDENTIAL_PROVIDER_ADDRESS:-credprovider.ate-system.svc:50051}" + sed -e "s|--credential-provider-name=substrate-secret://kubernetes.io|--credential-provider-name=${name}|" \ + -e "s|--credential-provider-address=credprovider.ate-system.svc:50051|--credential-provider-address=${addr}|" \ + manifests/egress-credential-injection/atenet-egress-inject.yaml +} + +# deploy_egress_credential_injection deploys the credential provider and the +# egress injector, then re-wires the egress gateway to dial the injector's +# ext_proc Service. The prescan sets ATE_EXPERIMENTAL_USE_SDSMINT and +# ATE_ADDITIONAL_EGRESS_EXTPROC_SERVICE so apply_atenet_egress splices the +# injector's filter into the sdsmint egress bootstrap. +deploy_egress_credential_injection() { + log_step "deploy_egress_credential_injection" + if [[ "${ATE_EXPERIMENTAL_USE_SDSMINT:-false}" != "true" ]]; then + echo "Error: --experimental-egress-credential-injection requires --experimental-use-sdsmint" >&2 + return 1 + fi + ensure_crds + + run_kubectl apply -f manifests/ate-install/ate-system-namespace.yaml \ + && run_kubectl wait --for=jsonpath='{.status.phase}'=Active namespace/ate-system --timeout=60s + + # The authorization mapping and sample secret first, so the pods that mount + # them start cleanly. + run_kubectl apply -f manifests/egress-credential-injection/namespace-policy.yaml + run_kubectl apply -f manifests/egress-credential-injection/sample-secret.yaml + + # The provider and the injector. Roll both out before wiring the gateway: the + # gateway's ext_proc filter is failure_mode_allow: false, so the Service must + # be answering before it starts receiving traffic. + run_ko apply -f manifests/egress-credential-injection/credprovider.yaml + render_atenet_egress_inject_manifest | run_ko apply -f - + run_kubectl rollout status deployment/credprovider -n ate-system --timeout="$(rollout_timeout)" + run_kubectl rollout status deployment/atenet-egress-inject -n ate-system --timeout="$(rollout_timeout)" + + # Re-wire the egress gateway to splice in the injector's ext_proc filter. + ensure_egress_mitm_ca_pool_secret + apply_atenet_egress + run_kubectl rollout status deployment/atenet-egress -n ate-system --timeout="$(rollout_timeout)" +} + # get_actor_state echoes the actor's state enum (e.g. ACTOR_STATE_SUSPENDED). get_actor_state() { local actor_name="$1" @@ -992,6 +1052,36 @@ for ((i = 0; i < ${#prescan_args[@]}; i++)); do fi ATE_ADDITIONAL_EGRESS_EXTPROC_SERVICE="${prescan_args[$((i + 1))]}" ;; + # Deploying the credential-injection stack implies sdsmint and points the + # egress gateway's additional ext_proc filter at the injector Service. Set + # in the prescan so any apply_atenet_egress in this invocation is wired, + # whether from this flag's own function or a co-passed --deploy-atenet. + --experimental-egress-credential-injection) + ATE_EXPERIMENTAL_USE_SDSMINT=true + ATE_ADDITIONAL_EGRESS_EXTPROC_SERVICE="ate-system/atenet-egress-inject:50051" + ;; + # Read in the prescan so the values are set before the main loop dispatches + # deploy_egress_credential_injection, regardless of flag order in argv. + --credential-provider-name=*) + ATE_CREDENTIAL_PROVIDER_NAME="${prescan_args[i]#*=}" + ;; + --credential-provider-name) + if (( i + 1 >= ${#prescan_args[@]} )); then + echo "Error: --credential-provider-name requires a value" >&2 + exit 1 + fi + ATE_CREDENTIAL_PROVIDER_NAME="${prescan_args[$((i + 1))]}" + ;; + --credential-provider-address=*) + ATE_CREDENTIAL_PROVIDER_ADDRESS="${prescan_args[i]#*=}" + ;; + --credential-provider-address) + if (( i + 1 >= ${#prescan_args[@]} )); then + echo "Error: --credential-provider-address requires :" >&2 + exit 1 + fi + ATE_CREDENTIAL_PROVIDER_ADDRESS="${prescan_args[$((i + 1))]}" + ;; --podcert-workers-per-signer=*) ATE_INSTALL_PODCERT_WORKERS_PER_SIGNER="${prescan_args[i]#*=}" ;; --podcert-workers-per-signer) if (( i + 1 >= ${#prescan_args[@]} )); then @@ -1086,6 +1176,13 @@ while [[ "$#" -gt 0 ]]; do --experimental-use-sdsmint) ;; --experimental-additional-egress-extproc-service) shift ;; --experimental-additional-egress-extproc-service=*) ;; + --experimental-egress-credential-injection) deploy_egress_credential_injection ;; + # Captured in the pre-scan above; matched here only so they are consumed and + # the `*)` branch does not reject them as unknown options. + --credential-provider-name) shift ;; + --credential-provider-name=*) ;; + --credential-provider-address) shift ;; + --credential-provider-address=*) ;; --podcert-workers-per-signer=*) ATE_INSTALL_PODCERT_WORKERS_PER_SIGNER="${1#*=}" ;; --podcert-workers-per-signer) shift diff --git a/internal/actorspiffe/actorspiffe.go b/internal/actorspiffe/actorspiffe.go new file mode 100644 index 0000000000..bf4c7ba4fa --- /dev/null +++ b/internal/actorspiffe/actorspiffe.go @@ -0,0 +1,53 @@ +// 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 actorspiffe parses the SPIFFE URI that identifies an actor. The URI is +// minted by cmd/ateapi/internal/actoridentity and carried on the actor's +// CA-signed client certificate; the egress gateway and the credential provider +// both authorize on the atespace and actor name it encodes. +package actorspiffe + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +// TrustDomain is the SPIFFE host an actor identity URI carries, matching the URI +// minted in cmd/ateapi/internal/actoridentity. +const TrustDomain = "substrate-actor.local" + +// Parse extracts the atespace and actor name from an actor SPIFFE URI of the +// form spiffe://substrate-actor.local/atespace//actor/. +func Parse(raw string) (atespace, name string, err error) { + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("parsing actor identity %q: %w", raw, err) + } + if u.Scheme != "spiffe" { + return "", "", fmt.Errorf("actor identity %q: scheme is %q, want spiffe", raw, u.Scheme) + } + if u.Host != TrustDomain { + return "", "", fmt.Errorf("actor identity %q: trust domain is %q, want %q", raw, u.Host, TrustDomain) + } + segments := strings.Split(strings.Trim(path.Clean(u.Path), "/"), "/") + if len(segments) != 4 || segments[0] != "atespace" || segments[2] != "actor" { + return "", "", fmt.Errorf("actor identity %q: want /atespace//actor/", raw) + } + if segments[1] == "" || segments[3] == "" { + return "", "", fmt.Errorf("actor identity %q: empty atespace or actor name", raw) + } + return segments[1], segments[3], nil +} diff --git a/internal/actorspiffe/actorspiffe_test.go b/internal/actorspiffe/actorspiffe_test.go new file mode 100644 index 0000000000..aad5194936 --- /dev/null +++ b/internal/actorspiffe/actorspiffe_test.go @@ -0,0 +1,56 @@ +// 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 actorspiffe + +import "testing" + +func TestParse(t *testing.T) { + tests := []struct { + name string + uri string + wantAtespace string + wantActor string + wantErr bool + }{ + { + name: "valid", + uri: "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor", + wantAtespace: "team-a", + wantActor: "my-actor", + }, + {name: "wrong scheme", uri: "https://substrate-actor.local/atespace/team-a/actor/my-actor", wantErr: true}, + {name: "wrong trust domain", uri: "spiffe://other.local/atespace/team-a/actor/my-actor", wantErr: true}, + {name: "wrong structure", uri: "spiffe://substrate-actor.local/ns/team-a/actor/my-actor", wantErr: true}, + {name: "short path", uri: "spiffe://substrate-actor.local/atespace/team-a", wantErr: true}, + {name: "empty", uri: "", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + atespace, actor, err := Parse(tc.uri) + if tc.wantErr { + if err == nil { + t.Fatalf("Parse(%q) = (%q, %q), want error", tc.uri, atespace, actor) + } + return + } + if err != nil { + t.Fatalf("Parse(%q) unexpected error: %v", tc.uri, err) + } + if atespace != tc.wantAtespace || actor != tc.wantActor { + t.Errorf("Parse(%q) = (%q, %q), want (%q, %q)", tc.uri, atespace, actor, tc.wantAtespace, tc.wantActor) + } + }) + } +} diff --git a/internal/proto/nsauthzpb/gen.go b/internal/proto/nsauthzpb/gen.go new file mode 100644 index 0000000000..2371d63e0d --- /dev/null +++ b/internal/proto/nsauthzpb/gen.go @@ -0,0 +1,17 @@ +// 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 nsauthzpb + +//go:generate bash -c "../../../hack/protoc.sh --plugin=protoc-gen-go=$(bash ../../../hack/run-tool.sh --print-bin-path protoc-gen-go) --go_out=paths=source_relative:. nsauthz.proto" diff --git a/internal/proto/nsauthzpb/nsauthz.pb.go b/internal/proto/nsauthzpb/nsauthz.pb.go new file mode 100644 index 0000000000..248615684b --- /dev/null +++ b/internal/proto/nsauthzpb/nsauthz.pb.go @@ -0,0 +1,201 @@ +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11-devel +// protoc v4.25.3 +// source: nsauthz.proto + +package nsauthzpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// NamespaceAuthorizationFile is the top-level container the credential provider +// loads from a textproto file at startup. It maps each atespace to the +// Kubernetes namespaces whose Secrets that atespace may resolve. Enforcement is +// default-deny: an atespace absent from this file can resolve nothing. This is a +// POC stand-in for a future authorization API. +type NamespaceAuthorizationFile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Policy []*AtespaceNamespacePolicy `protobuf:"bytes,1,rep,name=policy,proto3" json:"policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NamespaceAuthorizationFile) Reset() { + *x = NamespaceAuthorizationFile{} + mi := &file_nsauthz_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NamespaceAuthorizationFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NamespaceAuthorizationFile) ProtoMessage() {} + +func (x *NamespaceAuthorizationFile) ProtoReflect() protoreflect.Message { + mi := &file_nsauthz_proto_msgTypes[0] + 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 NamespaceAuthorizationFile.ProtoReflect.Descriptor instead. +func (*NamespaceAuthorizationFile) Descriptor() ([]byte, []int) { + return file_nsauthz_proto_rawDescGZIP(), []int{0} +} + +func (x *NamespaceAuthorizationFile) GetPolicy() []*AtespaceNamespacePolicy { + if x != nil { + return x.Policy + } + return nil +} + +// AtespaceNamespacePolicy grants one atespace access to a set of namespaces. +type AtespaceNamespacePolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The atespace this grant applies to, as parsed from the actor SPIFFE URI. + Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` + // The namespaces whose Secrets the atespace may resolve. + AllowedNamespaces []string `protobuf:"bytes,2,rep,name=allowed_namespaces,json=allowedNamespaces,proto3" json:"allowed_namespaces,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AtespaceNamespacePolicy) Reset() { + *x = AtespaceNamespacePolicy{} + mi := &file_nsauthz_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AtespaceNamespacePolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AtespaceNamespacePolicy) ProtoMessage() {} + +func (x *AtespaceNamespacePolicy) ProtoReflect() protoreflect.Message { + mi := &file_nsauthz_proto_msgTypes[1] + 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 AtespaceNamespacePolicy.ProtoReflect.Descriptor instead. +func (*AtespaceNamespacePolicy) Descriptor() ([]byte, []int) { + return file_nsauthz_proto_rawDescGZIP(), []int{1} +} + +func (x *AtespaceNamespacePolicy) GetAtespace() string { + if x != nil { + return x.Atespace + } + return "" +} + +func (x *AtespaceNamespacePolicy) GetAllowedNamespaces() []string { + if x != nil { + return x.AllowedNamespaces + } + return nil +} + +var File_nsauthz_proto protoreflect.FileDescriptor + +const file_nsauthz_proto_rawDesc = "" + + "\n" + + "\rnsauthz.proto\x12\ansauthz\"V\n" + + "\x1aNamespaceAuthorizationFile\x128\n" + + "\x06policy\x18\x01 \x03(\v2 .nsauthz.AtespaceNamespacePolicyR\x06policy\"d\n" + + "\x17AtespaceNamespacePolicy\x12\x1a\n" + + "\batespace\x18\x01 \x01(\tR\batespace\x12-\n" + + "\x12allowed_namespaces\x18\x02 \x03(\tR\x11allowedNamespacesB?Z=github.com/agent-substrate/substrate/internal/proto/nsauthzpbb\x06proto3" + +var ( + file_nsauthz_proto_rawDescOnce sync.Once + file_nsauthz_proto_rawDescData []byte +) + +func file_nsauthz_proto_rawDescGZIP() []byte { + file_nsauthz_proto_rawDescOnce.Do(func() { + file_nsauthz_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_nsauthz_proto_rawDesc), len(file_nsauthz_proto_rawDesc))) + }) + return file_nsauthz_proto_rawDescData +} + +var file_nsauthz_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_nsauthz_proto_goTypes = []any{ + (*NamespaceAuthorizationFile)(nil), // 0: nsauthz.NamespaceAuthorizationFile + (*AtespaceNamespacePolicy)(nil), // 1: nsauthz.AtespaceNamespacePolicy +} +var file_nsauthz_proto_depIdxs = []int32{ + 1, // 0: nsauthz.NamespaceAuthorizationFile.policy:type_name -> nsauthz.AtespaceNamespacePolicy + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_nsauthz_proto_init() } +func file_nsauthz_proto_init() { + if File_nsauthz_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_nsauthz_proto_rawDesc), len(file_nsauthz_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_nsauthz_proto_goTypes, + DependencyIndexes: file_nsauthz_proto_depIdxs, + MessageInfos: file_nsauthz_proto_msgTypes, + }.Build() + File_nsauthz_proto = out.File + file_nsauthz_proto_goTypes = nil + file_nsauthz_proto_depIdxs = nil +} diff --git a/internal/proto/nsauthzpb/nsauthz.proto b/internal/proto/nsauthzpb/nsauthz.proto new file mode 100644 index 0000000000..efdbbec940 --- /dev/null +++ b/internal/proto/nsauthzpb/nsauthz.proto @@ -0,0 +1,36 @@ +// 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. + +syntax = "proto3"; + +package nsauthz; + +option go_package = "github.com/agent-substrate/substrate/internal/proto/nsauthzpb"; + +// NamespaceAuthorizationFile is the top-level container the credential provider +// loads from a textproto file at startup. It maps each atespace to the +// Kubernetes namespaces whose Secrets that atespace may resolve. Enforcement is +// default-deny: an atespace absent from this file can resolve nothing. This is a +// POC stand-in for a future authorization API. +message NamespaceAuthorizationFile { + repeated AtespaceNamespacePolicy policy = 1; +} + +// AtespaceNamespacePolicy grants one atespace access to a set of namespaces. +message AtespaceNamespacePolicy { + // The atespace this grant applies to, as parsed from the actor SPIFFE URI. + string atespace = 1; + // The namespaces whose Secrets the atespace may resolve. + repeated string allowed_namespaces = 2; +} diff --git a/manifests/egress-credential-injection/README.md b/manifests/egress-credential-injection/README.md new file mode 100644 index 0000000000..51450d0c7d --- /dev/null +++ b/manifests/egress-credential-injection/README.md @@ -0,0 +1,148 @@ +# Egress credential injection (POC) + +A proof of concept for the Substrate Credential Provider design's **egress +credential injection** scope: transparently adding a credential (e.g. +`Authorization: Bearer `) to an actor's outbound request based on an +egress policy, with the secret fetched on demand from an external store that +Substrate never persists. + +## Pieces + +- **`credprovider`** (`cmd/credprovider`) — a gRPC service implementing the + `CredentialProvider.RequestSecret` plugin API (`pkg/proto/credproviderpb`), + backed by Kubernetes Secrets. It resolves + `substrate-secret://kubernetes.io///[/]`. + It is the only component here with Kubernetes access. +- **`atenet egress-inject`** (`cmd/atenet/internal/router/egressinject`) — the + ext_proc server the egress gateway's MITM leg dials + (`additional_egress_ext_proc`). For each decrypted request it fetches the + requesting actor's egress policy from `ateapi` and, on a matching rule that + carries a credential injection, calls `credprovider` and injects the header. +- **egress policy** — the actor egress policy resource served by the `ateapi` + `Control` API (`GetActorEgressPolicy`, `pkg/proto/ateapipb`). Each actor has at + most one policy (named `default`); its `hostnames` rules carry the + `inject_static_headers` effects the injector applies. Create one with + `CreateActorEgressPolicy`. +- **namespace authorization** (`internal/proto/nsauthzpb`) — an atespace→namespace + mapping `credprovider` enforces (default-deny) so an atespace can only resolve + secrets from its permitted namespaces. Loaded from the + `credprovider-namespace-policy` ConfigMap in `namespace-policy.yaml`. + +## Request flow + +``` +actor --HTTPS--> egress gateway (MITM: terminates TLS with an sdsmint leaf) + | + | decrypted request, on the MITM leg + v + atenet egress-inject (ext_proc) + | GetActorEgressPolicy(actor from dev.ate.actor.identity) + | match Host against the policy's rules + v + credprovider.RequestSecret(uri, context) + | read K8s Secret + v + header mutation: Authorization: Bearer + | + v + re-originated TLS to the real origin, credential attached +``` + +Actor identity comes from the CA-signed client cert the gateway verified on the +CONNECT leg, relayed to the injector as the `dev.ate.actor.identity` filter-state +attribute — never from a client-supplied header. + +The same injector runs on both the MITM leg's TLS chain (HTTPS) and its cleartext +chain (plaintext HTTP). Because the cleartext path re-originates without upstream +TLS, injecting a credential there sends it in the clear to the origin — so the +injector **always refuses** to inject a credential on a cleartext request and +fails closed. (The actor egress policy API models no per-rule cleartext opt-in.) + +## Install + +One flag deploys the whole stack (provider, injector, namespace policy, sample +secret) and wires the sdsmint egress gateway to the injector: + +``` +hack/install-ate.sh --experimental-egress-credential-injection +``` + +It implies `--experimental-use-sdsmint` and requires the (default) +`--atenet-router=envoy`. Two flags configure which credential provider the +injector uses: + +- `--credential-provider-name NAME` — the provider the injector serves, as a + `substrate-secret://` class prefix (e.g. `substrate-secret://kubernetes.io`); a + policy credential URI of any other class is refused. Default + `substrate-secret://kubernetes.io`. +- `--credential-provider-address HOST:PORT` — where the injector dials the + provider. Default `credprovider.ate-system.svc:50051`. + +To deploy the pieces by hand instead, see the manifests in this directory and the +underlying +`--experimental-additional-egress-extproc-service ate-system/atenet-egress-inject:50051` +flag. + +## Verify + +First give the actor an egress policy through the `ateapi` `Control` API. Create +one `default` policy for atespace `team-a`, actor `my-actor` with a `hostnames` +rule for `api.example.com` whose `inject_static_headers` effect sets header +`Authorization`, prefix `Bearer `, and credential URI +`substrate-secret://kubernetes.io/team-secrets/ns1/example-api` +(`CreateActorEgressPolicy`). The change takes effect immediately — the injector +fetches the policy per request, no restart needed. + +Then, from that actor (provisioned to trust the MITM CA, see +`demos/egress/egress-mitm.yaml.tmpl`): + +``` +curl https://api.example.com/anything +``` + +The origin should see `Authorization: Bearer poc-example-api-token`. A request to +a host not covered by the policy is passed through unchanged (the injector is +started with `--on-no-match=allow`). + +A plaintext `curl http://api.example.com/anything` is always denied 403 (the +origin never sees the credential; there is no cleartext opt-in). A request whose +atespace is not granted the secret's namespace in `namespace-policy.yaml` is +denied by `credprovider`, and the injector fails closed (503). A policy whose +credential URI names a provider class other than the injector's +`--credential-provider-name` (default `substrate-secret://kubernetes.io`) is +refused before the provider is dialed. + +The resolved secret is sanitized before it becomes a header value: a trailing +newline (common when a Secret is created from a file) is trimmed, and a secret +that is empty or contains a control character — which Envoy would reject and, for +CR/LF, could turn into header injection — fails closed (503) rather than being +sent as a bare `Bearer ` or a malformed header. + +## POC simplifications (not production-ready) + +- **Attestation**: the injector passes the actor's SPIFFE URI as the attested + `actor_identity`. Production should pass a verifiable Actor JWT (a `MintJWT` + RPC exists but is not yet integrated) so the provider can independently verify + the caller's assertion. +- **No caching**: both the egress policy and the credential are fetched per + request — the policy from `ateapi`, the credential from `credprovider`. A real + deployment needs caching (with a TTL/revocation story) to bound the load each + egress request puts on the control plane and the provider. +- **Broad RBAC**: `credprovider` enforces the atespace→namespace mapping in + process, but is still granted `get secrets` cluster-wide. The defense-in-depth + follow-up is to scope the RBAC to the served namespaces so the mapping is not + the only gate. +- **Static namespace mapping**: the namespace mapping is loaded once at startup, + so editing the `credprovider-namespace-policy` ConfigMap requires restarting + `credprovider`; a watch-based reload is a follow-up. (The egress policy is now + live via `ateapi` and needs no restart.) +- **Blast radius**: injected secrets are plaintext in the shared gateway data + plane. See the design's "sealed credentials (AEAD)" note. +- **`ip_blocks` rules are not evaluated**: the injector matches on hostname and + has no original destination IP on the MITM leg, so it cannot evaluate an + `ip_blocks` egress rule and currently skips it (treated as non-matching). A + policy that orders an `ip_blocks` rule before a hostname rule is therefore not + faithfully reproduced here — the control plane's first-match ordering may + differ. Avoid pairing `ip_blocks` rules with credential injection until the + destination IP is plumbed to this leg. +- Only the `kubernetes.io` provider class is implemented. diff --git a/manifests/egress-credential-injection/atenet-egress-inject.yaml b/manifests/egress-credential-injection/atenet-egress-inject.yaml new file mode 100644 index 0000000000..e3e94679b1 --- /dev/null +++ b/manifests/egress-credential-injection/atenet-egress-inject.yaml @@ -0,0 +1,148 @@ +# 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. + +# The egress credential injector: the ext_proc server the egress gateway's MITM +# leg dials (cluster additional_egress_ext_proc). It fetches the requesting +# actor's egress policy from ateapi and, on a matching rule that carries a +# credential injection, fetches the credential from credprovider and injects it +# as a request header. +# +# Wire the gateway to this service by installing MITM egress with: +# hack/install-ate.sh --experimental-use-sdsmint \ +# --experimental-additional-egress-extproc-service ate-system/atenet-egress-inject:50051 +# +# Like atenet-egress, this ServiceAccount is bound to no RBAC on purpose: the +# injector has no Kubernetes access. It reads egress policy from ateapi over +# mTLS; only credprovider reads Secrets. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: atenet-egress-inject + namespace: ate-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: atenet-egress-inject + namespace: ate-system + labels: + app: atenet-egress-inject +spec: + replicas: 1 + selector: + matchLabels: + app: atenet-egress-inject + template: + metadata: + labels: + app: atenet-egress-inject + spec: + serviceAccountName: atenet-egress-inject + securityContext: + runAsUser: 65532 + runAsGroup: 65532 + runAsNonRoot: true + containers: + - name: egress-inject + image: ko://github.com/agent-substrate/substrate/cmd/atenet + args: + - "egress-inject" + - "--port-extproc=50051" + - "--metrics-address=:9090" + - "--on-no-match=allow" + # Serve mTLS to the gateway: present the pod's servicedns identity (SAN + # atenet-egress-inject.ate-system.svc, which the gateway pins) and verify + # the gateway's podidentity client cert. + - "--server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem" + - "--client-ca-file=/run/podidentity.podcert.ate.dev/trust-bundle.pem" + # Dial credprovider over mTLS: present podidentity, validate its servicedns + # serving cert against the servicedns CA, pin its SAN. --credential-provider-name + # is the provider class this injector serves; a policy URI of another class is refused. + - "--credential-provider-name=substrate-secret://kubernetes.io" + - "--credential-provider-address=credprovider.ate-system.svc:50051" + - "--provider-client-cert=/run/podidentity.podcert.ate.dev/credential-bundle.pem" + - "--provider-ca-file=/run/servicedns.podcert.ate.dev/trust-bundle.pem" + - "--provider-server-name=credprovider.ate-system.svc" + # Fetch each actor's egress policy from ateapi over mTLS: present + # podidentity, validate ateapi's servicedns serving cert against the + # servicedns CA. Dialed by DNS (not the k8s:/// resolver) because the + # injector has no Kubernetes access to read endpoints. + - "--ateapi-address=dns:///api.ate-system.svc:443" + - "--ateapi-ca-file=/run/servicedns.podcert.ate.dev/trust-bundle.pem" + - "--ateapi-client-cert=/run/podidentity.podcert.ate.dev/credential-bundle.pem" + - "--log-level=info" + ports: + - name: grpc + containerPort: 50051 + - name: metrics + containerPort: 9090 + readinessProbe: + httpGet: + path: /readyz + port: metrics + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: servicedns + mountPath: /run/servicedns.podcert.ate.dev + readOnly: true + - name: podidentity + mountPath: /run/podidentity.podcert.ate.dev + readOnly: true + volumes: + - name: servicedns + projected: + sources: + - podCertificate: + signerName: servicedns.podcert.ate.dev/identity + keyType: ECDSAP256 + credentialBundlePath: credential-bundle.pem + - clusterTrustBundle: + signerName: servicedns.podcert.ate.dev/identity + labelSelector: + matchLabels: + podcert.ate.dev/canarying: live + path: trust-bundle.pem + - name: podidentity + projected: + sources: + - podCertificate: + signerName: podidentity.podcert.ate.dev/identity + keyType: ECDSAP256 + credentialBundlePath: credential-bundle.pem + - clusterTrustBundle: + signerName: podidentity.podcert.ate.dev/identity + labelSelector: + matchLabels: + podcert.ate.dev/canarying: live + path: trust-bundle.pem +--- +apiVersion: v1 +kind: Service +metadata: + name: atenet-egress-inject + namespace: ate-system +spec: + type: ClusterIP + selector: + app: atenet-egress-inject + ports: + - name: grpc + port: 50051 + targetPort: grpc + protocol: TCP diff --git a/manifests/egress-credential-injection/credprovider.yaml b/manifests/egress-credential-injection/credprovider.yaml new file mode 100644 index 0000000000..f6a8f75d1b --- /dev/null +++ b/manifests/egress-credential-injection/credprovider.yaml @@ -0,0 +1,157 @@ +# 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. + +# The credential provider: a gRPC service that resolves substrate-secret:// URIs +# of the kubernetes.io class to Kubernetes Secret values. It is the ONLY +# component in the egress credential-injection path with Kubernetes access; the +# egress gateway and the injector never read Secrets. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: credprovider + namespace: ate-system +--- +# POC scope note: this grants read on Secrets cluster-wide so a policy can name a +# Secret in any namespace. A production deployment would scope this to the +# namespaces a provider instance is allowed to serve. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: credprovider-secret-reader +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: credprovider-secret-reader +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: credprovider-secret-reader +subjects: +- kind: ServiceAccount + name: credprovider + namespace: ate-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: credprovider + namespace: ate-system + labels: + app: credprovider +spec: + replicas: 1 + selector: + matchLabels: + app: credprovider + template: + metadata: + labels: + app: credprovider + spec: + serviceAccountName: credprovider + securityContext: + runAsUser: 65532 + runAsGroup: 65532 + runAsNonRoot: true + containers: + - name: credprovider + image: ko://github.com/agent-substrate/substrate/cmd/credprovider + args: + - "--listen-address=:50051" + - "--metrics-address=:9090" + # Serve with the pod's servicedns identity (SAN credprovider.ate-system.svc) + # and require the injector to present a podidentity client cert. + - "--server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem" + - "--client-ca-file=/run/podidentity.podcert.ate.dev/trust-bundle.pem" + # The sample Secret carries a single key "token"; make it the default so a + # URI without a key resolves. + - "--default-secret-key=token" + # Enforce the atespace→namespace authorization policy (default-deny). + - "--namespace-policy-file=/etc/credprovider/namespace-policy.textproto" + - "--log-level=info" + ports: + - name: grpc + containerPort: 50051 + - name: metrics + containerPort: 9090 + readinessProbe: + httpGet: + path: /readyz + port: metrics + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: namespace-policy + mountPath: /etc/credprovider + readOnly: true + - name: servicedns + mountPath: /run/servicedns.podcert.ate.dev + readOnly: true + - name: podidentity + mountPath: /run/podidentity.podcert.ate.dev + readOnly: true + volumes: + - name: namespace-policy + configMap: + name: credprovider-namespace-policy + - name: servicedns + projected: + sources: + - podCertificate: + signerName: servicedns.podcert.ate.dev/identity + keyType: ECDSAP256 + credentialBundlePath: credential-bundle.pem + - clusterTrustBundle: + signerName: servicedns.podcert.ate.dev/identity + labelSelector: + matchLabels: + podcert.ate.dev/canarying: live + path: trust-bundle.pem + - name: podidentity + projected: + sources: + - podCertificate: + signerName: podidentity.podcert.ate.dev/identity + keyType: ECDSAP256 + credentialBundlePath: credential-bundle.pem + - clusterTrustBundle: + signerName: podidentity.podcert.ate.dev/identity + labelSelector: + matchLabels: + podcert.ate.dev/canarying: live + path: trust-bundle.pem +--- +apiVersion: v1 +kind: Service +metadata: + name: credprovider + namespace: ate-system +spec: + type: ClusterIP + selector: + app: credprovider + ports: + - name: grpc + port: 50051 + targetPort: grpc + protocol: TCP diff --git a/manifests/egress-credential-injection/namespace-policy.yaml b/manifests/egress-credential-injection/namespace-policy.yaml new file mode 100644 index 0000000000..bbc6dceb0a --- /dev/null +++ b/manifests/egress-credential-injection/namespace-policy.yaml @@ -0,0 +1,32 @@ +# 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. + +# The atespace→namespace authorization policy credprovider enforces: an actor's +# atespace may only resolve Secrets whose URI namespace is listed for it here. +# Enforcement is default-deny — an atespace absent from this file resolves +# nothing. credprovider loads it once at startup, so editing this ConfigMap +# requires restarting the credprovider Deployment to take effect. +apiVersion: v1 +kind: ConfigMap +metadata: + name: credprovider-namespace-policy + namespace: ate-system +data: + namespace-policy.textproto: | + # atespace "team-a" may resolve secrets in namespace "ns1" (matches the + # sample policy's substrate-secret://kubernetes.io/team-secrets/ns1/example-api). + policy { + atespace: "team-a" + allowed_namespaces: "ns1" + } diff --git a/manifests/egress-credential-injection/sample-secret.yaml b/manifests/egress-credential-injection/sample-secret.yaml new file mode 100644 index 0000000000..67cd8b263d --- /dev/null +++ b/manifests/egress-credential-injection/sample-secret.yaml @@ -0,0 +1,31 @@ +# 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. + +# The sample credential the policy injects. The URI in the sample policy, +# substrate-secret://kubernetes.io/team-secrets/ns1/example-api +# resolves to namespace "ns1", Secret "example-api". credprovider is started +# with --default-secret-key=token, so the "token" key below is returned. +apiVersion: v1 +kind: Namespace +metadata: + name: ns1 +--- +apiVersion: v1 +kind: Secret +metadata: + name: example-api + namespace: ns1 +type: Opaque +stringData: + token: "poc-example-api-token" diff --git a/pkg/proto/credproviderpb/credprovider.pb.go b/pkg/proto/credproviderpb/credprovider.pb.go new file mode 100644 index 0000000000..51c2e6736f --- /dev/null +++ b/pkg/proto/credproviderpb/credprovider.pb.go @@ -0,0 +1,257 @@ +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11-devel +// protoc v4.25.3 +// source: credprovider.proto + +package credproviderpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SecretRequestContext is the attested context Substrate passes with a request. +// The provider is trusted to have authenticated the caller (mutual TLS); the +// caller is trusted to have attested the actor identity. +type SecretRequestContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The attested actor identity on whose behalf the secret is fetched. Today + // this is the actor's SPIFFE URI as verified by the egress gateway; a + // verifiable Actor JWT is the intended future form. + ActorIdentity string `protobuf:"bytes,1,opt,name=actor_identity,json=actorIdentity,proto3" json:"actor_identity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecretRequestContext) Reset() { + *x = SecretRequestContext{} + mi := &file_credprovider_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecretRequestContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretRequestContext) ProtoMessage() {} + +func (x *SecretRequestContext) ProtoReflect() protoreflect.Message { + mi := &file_credprovider_proto_msgTypes[0] + 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 SecretRequestContext.ProtoReflect.Descriptor instead. +func (*SecretRequestContext) Descriptor() ([]byte, []int) { + return file_credprovider_proto_rawDescGZIP(), []int{0} +} + +func (x *SecretRequestContext) GetActorIdentity() string { + if x != nil { + return x.ActorIdentity + } + return "" +} + +// RequestSecretRequest asks a provider to resolve one credential URI. +type RequestSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A substrate-secret:// URI: + // substrate-secret://// + Uri string `protobuf:"bytes,1,opt,name=uri,proto3" json:"uri,omitempty"` + // The attested context for the request. + Context *SecretRequestContext `protobuf:"bytes,2,opt,name=context,proto3" json:"context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestSecretRequest) Reset() { + *x = RequestSecretRequest{} + mi := &file_credprovider_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestSecretRequest) ProtoMessage() {} + +func (x *RequestSecretRequest) ProtoReflect() protoreflect.Message { + mi := &file_credprovider_proto_msgTypes[1] + 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 RequestSecretRequest.ProtoReflect.Descriptor instead. +func (*RequestSecretRequest) Descriptor() ([]byte, []int) { + return file_credprovider_proto_rawDescGZIP(), []int{1} +} + +func (x *RequestSecretRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *RequestSecretRequest) GetContext() *SecretRequestContext { + if x != nil { + return x.Context + } + return nil +} + +// RequestSecretResponse carries the resolved secret material. +type RequestSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The raw secret bytes. The caller decides how to use them (e.g. as a bearer + // token); the provider does not format them. + Secret []byte `protobuf:"bytes,1,opt,name=secret,proto3" json:"secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestSecretResponse) Reset() { + *x = RequestSecretResponse{} + mi := &file_credprovider_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestSecretResponse) ProtoMessage() {} + +func (x *RequestSecretResponse) ProtoReflect() protoreflect.Message { + mi := &file_credprovider_proto_msgTypes[2] + 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 RequestSecretResponse.ProtoReflect.Descriptor instead. +func (*RequestSecretResponse) Descriptor() ([]byte, []int) { + return file_credprovider_proto_rawDescGZIP(), []int{2} +} + +func (x *RequestSecretResponse) GetSecret() []byte { + if x != nil { + return x.Secret + } + return nil +} + +var File_credprovider_proto protoreflect.FileDescriptor + +const file_credprovider_proto_rawDesc = "" + + "\n" + + "\x12credprovider.proto\x12\fcredprovider\"=\n" + + "\x14SecretRequestContext\x12%\n" + + "\x0eactor_identity\x18\x01 \x01(\tR\ractorIdentity\"f\n" + + "\x14RequestSecretRequest\x12\x10\n" + + "\x03uri\x18\x01 \x01(\tR\x03uri\x12<\n" + + "\acontext\x18\x02 \x01(\v2\".credprovider.SecretRequestContextR\acontext\"/\n" + + "\x15RequestSecretResponse\x12\x16\n" + + "\x06secret\x18\x01 \x01(\fR\x06secret2p\n" + + "\x12CredentialProvider\x12Z\n" + + "\rRequestSecret\x12\".credprovider.RequestSecretRequest\x1a#.credprovider.RequestSecretResponse\"\x00B?Z=github.com/agent-substrate/substrate/pkg/proto/credproviderpbb\x06proto3" + +var ( + file_credprovider_proto_rawDescOnce sync.Once + file_credprovider_proto_rawDescData []byte +) + +func file_credprovider_proto_rawDescGZIP() []byte { + file_credprovider_proto_rawDescOnce.Do(func() { + file_credprovider_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_credprovider_proto_rawDesc), len(file_credprovider_proto_rawDesc))) + }) + return file_credprovider_proto_rawDescData +} + +var file_credprovider_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_credprovider_proto_goTypes = []any{ + (*SecretRequestContext)(nil), // 0: credprovider.SecretRequestContext + (*RequestSecretRequest)(nil), // 1: credprovider.RequestSecretRequest + (*RequestSecretResponse)(nil), // 2: credprovider.RequestSecretResponse +} +var file_credprovider_proto_depIdxs = []int32{ + 0, // 0: credprovider.RequestSecretRequest.context:type_name -> credprovider.SecretRequestContext + 1, // 1: credprovider.CredentialProvider.RequestSecret:input_type -> credprovider.RequestSecretRequest + 2, // 2: credprovider.CredentialProvider.RequestSecret:output_type -> credprovider.RequestSecretResponse + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_credprovider_proto_init() } +func file_credprovider_proto_init() { + if File_credprovider_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_credprovider_proto_rawDesc), len(file_credprovider_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_credprovider_proto_goTypes, + DependencyIndexes: file_credprovider_proto_depIdxs, + MessageInfos: file_credprovider_proto_msgTypes, + }.Build() + File_credprovider_proto = out.File + file_credprovider_proto_goTypes = nil + file_credprovider_proto_depIdxs = nil +} diff --git a/pkg/proto/credproviderpb/credprovider.proto b/pkg/proto/credproviderpb/credprovider.proto new file mode 100644 index 0000000000..88cef0cb3e --- /dev/null +++ b/pkg/proto/credproviderpb/credprovider.proto @@ -0,0 +1,56 @@ +// 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. + +syntax = "proto3"; + +package credprovider; + +option go_package = "github.com/agent-substrate/substrate/pkg/proto/credproviderpb"; + +// CredentialProvider is the plugin API a secret backend implements so Substrate +// infrastructure (e.g. the egress gateway's credential injector) can fetch an +// external credential without Substrate storing it. A provider interprets the +// attested request context however is relevant to the store it fronts. +service CredentialProvider { + // RequestSecret resolves a credential URI to its secret material, subject to + // whatever authorization the provider applies to the request context. + rpc RequestSecret(RequestSecretRequest) returns (RequestSecretResponse) {} +} + +// SecretRequestContext is the attested context Substrate passes with a request. +// The provider is trusted to have authenticated the caller (mutual TLS); the +// caller is trusted to have attested the actor identity. +message SecretRequestContext { + // The attested actor identity on whose behalf the secret is fetched. Today + // this is the actor's SPIFFE URI as verified by the egress gateway; a + // verifiable Actor JWT is the intended future form. + string actor_identity = 1; +} + +// RequestSecretRequest asks a provider to resolve one credential URI. +message RequestSecretRequest { + // A substrate-secret:// URI: + // substrate-secret://// + string uri = 1; + + // The attested context for the request. + SecretRequestContext context = 2; +} + +// RequestSecretResponse carries the resolved secret material. +message RequestSecretResponse { + // The raw secret bytes. The caller decides how to use them (e.g. as a bearer + // token); the provider does not format them. + bytes secret = 1; +} diff --git a/pkg/proto/credproviderpb/credprovider_grpc.pb.go b/pkg/proto/credproviderpb/credprovider_grpc.pb.go new file mode 100644 index 0000000000..c294e2fe6e --- /dev/null +++ b/pkg/proto/credproviderpb/credprovider_grpc.pb.go @@ -0,0 +1,149 @@ +// 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. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v4.25.3 +// source: credprovider.proto + +package credproviderpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + CredentialProvider_RequestSecret_FullMethodName = "/credprovider.CredentialProvider/RequestSecret" +) + +// CredentialProviderClient is the client API for CredentialProvider service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// CredentialProvider is the plugin API a secret backend implements so Substrate +// infrastructure (e.g. the egress gateway's credential injector) can fetch an +// external credential without Substrate storing it. A provider interprets the +// attested request context however is relevant to the store it fronts. +type CredentialProviderClient interface { + // RequestSecret resolves a credential URI to its secret material, subject to + // whatever authorization the provider applies to the request context. + RequestSecret(ctx context.Context, in *RequestSecretRequest, opts ...grpc.CallOption) (*RequestSecretResponse, error) +} + +type credentialProviderClient struct { + cc grpc.ClientConnInterface +} + +func NewCredentialProviderClient(cc grpc.ClientConnInterface) CredentialProviderClient { + return &credentialProviderClient{cc} +} + +func (c *credentialProviderClient) RequestSecret(ctx context.Context, in *RequestSecretRequest, opts ...grpc.CallOption) (*RequestSecretResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RequestSecretResponse) + err := c.cc.Invoke(ctx, CredentialProvider_RequestSecret_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CredentialProviderServer is the server API for CredentialProvider service. +// All implementations must embed UnimplementedCredentialProviderServer +// for forward compatibility. +// +// CredentialProvider is the plugin API a secret backend implements so Substrate +// infrastructure (e.g. the egress gateway's credential injector) can fetch an +// external credential without Substrate storing it. A provider interprets the +// attested request context however is relevant to the store it fronts. +type CredentialProviderServer interface { + // RequestSecret resolves a credential URI to its secret material, subject to + // whatever authorization the provider applies to the request context. + RequestSecret(context.Context, *RequestSecretRequest) (*RequestSecretResponse, error) + mustEmbedUnimplementedCredentialProviderServer() +} + +// UnimplementedCredentialProviderServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedCredentialProviderServer struct{} + +func (UnimplementedCredentialProviderServer) RequestSecret(context.Context, *RequestSecretRequest) (*RequestSecretResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestSecret not implemented") +} +func (UnimplementedCredentialProviderServer) mustEmbedUnimplementedCredentialProviderServer() {} +func (UnimplementedCredentialProviderServer) testEmbeddedByValue() {} + +// UnsafeCredentialProviderServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CredentialProviderServer will +// result in compilation errors. +type UnsafeCredentialProviderServer interface { + mustEmbedUnimplementedCredentialProviderServer() +} + +func RegisterCredentialProviderServer(s grpc.ServiceRegistrar, srv CredentialProviderServer) { + // If the following call panics, it indicates UnimplementedCredentialProviderServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&CredentialProvider_ServiceDesc, srv) +} + +func _CredentialProvider_RequestSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestSecretRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CredentialProviderServer).RequestSecret(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CredentialProvider_RequestSecret_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CredentialProviderServer).RequestSecret(ctx, req.(*RequestSecretRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CredentialProvider_ServiceDesc is the grpc.ServiceDesc for CredentialProvider service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CredentialProvider_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "credprovider.CredentialProvider", + HandlerType: (*CredentialProviderServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "RequestSecret", + Handler: _CredentialProvider_RequestSecret_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "credprovider.proto", +} diff --git a/pkg/proto/credproviderpb/gen.go b/pkg/proto/credproviderpb/gen.go new file mode 100644 index 0000000000..7ac0cc97b0 --- /dev/null +++ b/pkg/proto/credproviderpb/gen.go @@ -0,0 +1,17 @@ +// 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 credproviderpb + +//go:generate bash -c "../../../hack/protoc.sh --plugin=protoc-gen-go=$(bash ../../../hack/run-tool.sh --print-bin-path protoc-gen-go) --plugin=protoc-gen-go-grpc=$(bash ../../../hack/run-tool.sh --print-bin-path protoc-gen-go-grpc) --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. credprovider.proto"