diff --git a/cmd/credprovider/internal/kubeprovider/kubeprovider.go b/cmd/credprovider/internal/kubeprovider/kubeprovider.go new file mode 100644 index 0000000000..94dc702b20 --- /dev/null +++ b/cmd/credprovider/internal/kubeprovider/kubeprovider.go @@ -0,0 +1,190 @@ +// 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" +) + +// ProviderName is the substrate-secret:// URI host this backend serves. +const ProviderName = "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 != ProviderName { + return SecretRef{}, fmt.Errorf("credential URI %q: provider class is %q, this provider serves %q", raw, u.Host, ProviderName) + } + + 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 + // 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. nsAuth enforces the +// atespace→namespace policy; pass nil to disable authorization (dev only). +func NewServer(client kubernetes.Interface, nsAuth *NamespaceAuthorizer) *Server { + return &Server{client: client, 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) + 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 sole key of a single-key Secret. A URI without a key resolving a +// multi-key Secret is an error rather than an ambiguous guess. +func selectKey(data map[string][]byte, uriKey string) ([]byte, error) { + if uriKey == "" { + if len(data) != 1 { + return nil, fmt.Errorf("no key given and the secret has %d keys; specify one in the URI", len(data)) + } + for _, v := range data { + return v, nil + } + } + v, ok := data[uriKey] + if !ok { + return nil, fmt.Errorf("key %q not present", uriKey) + } + 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..5f224909ff --- /dev/null +++ b/cmd/credprovider/internal/kubeprovider/kubeprovider_test.go @@ -0,0 +1,263 @@ +// 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/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 TestNamespaceAuthorizer(t *testing.T) { + authz, err := newNamespaceAuthorizer(namespacePolicyFile{ + Policies: []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(namespacePolicyFile{}) + 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(namespacePolicyFile{ + Policies: []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(namespacePolicyFile{ + Policies: []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 + uri string + want string + wantCode codes.Code + }{ + { + name: "explicit key", + uri: "substrate-secret://kubernetes.io/p/ns1/example-api/token", + 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, 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..f644938151 --- /dev/null +++ b/cmd/credprovider/internal/kubeprovider/nsauthz.go @@ -0,0 +1,87 @@ +// 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" + + "sigs.k8s.io/yaml" +) + +// namespacePolicyFile is the YAML the authorizer loads: a list of grants, each +// mapping one atespace to the namespaces whose Secrets it may resolve. +type namespacePolicyFile struct { + Policies []atespaceNamespacePolicy `json:"policies"` +} + +type atespaceNamespacePolicy struct { + Atespace string `json:"atespace"` + AllowedNamespaces []string `json:"allowedNamespaces"` +} + +// 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 the YAML policy file at 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 namespacePolicyFile + if err := yaml.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 a parsed policy file, +// validating that each grant names an atespace. +func newNamespaceAuthorizer(file namespacePolicyFile) (*NamespaceAuthorizer, error) { + allowed := make(map[string]map[string]struct{}) + for i, p := range file.Policies { + if p.Atespace == "" { + return nil, fmt.Errorf("namespace policy %d: atespace is required", i) + } + set := allowed[p.Atespace] + if set == nil { + set = make(map[string]struct{}) + allowed[p.Atespace] = set + } + for _, ns := range p.AllowedNamespaces { + 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..86798ea95b --- /dev/null +++ b/cmd/credprovider/main.go @@ -0,0 +1,188 @@ +// 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") + nsPolicyFile = pflag.String("namespace-policy-file", "", "path to the atespace→namespace authorization YAML; 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, 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/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/manifests/egress-credential-injection/credprovider.yaml b/manifests/egress-credential-injection/credprovider.yaml new file mode 100644 index 0000000000..3c71af0a1c --- /dev/null +++ b/manifests/egress-credential-injection/credprovider.yaml @@ -0,0 +1,154 @@ +# 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" + # Enforce the atespace→namespace authorization policy (default-deny). + - "--namespace-policy-file=/etc/credprovider/namespace-policy.yaml" + - "--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..829f8f3301 --- /dev/null +++ b/manifests/egress-credential-injection/namespace-policy.yaml @@ -0,0 +1,34 @@ +# 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. +# +# TODO: credprovider loads this once at startup, so editing this ConfigMap +# requires restarting the credprovider Deployment. Make it reload dynamically. +apiVersion: v1 +kind: ConfigMap +metadata: + name: credprovider-namespace-policy + namespace: ate-system +data: + namespace-policy.yaml: | + # atespace "team-a" may resolve secrets in namespace "ns1" (matches the + # sample policy's substrate-secret://kubernetes.io/team-secrets/ns1/example-api). + policies: + - atespace: team-a + allowedNamespaces: + - 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..8dfe1a438c --- /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". The URI omits a key, and the +# Secret has exactly one ("token"), so that sole key 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"