From e87e2f41a15568f5523f284f97d94c9ac54e8a56 Mon Sep 17 00:00:00 2001 From: Yufan Su Date: Mon, 31 Aug 2026 20:34:41 -0700 Subject: [PATCH 1/2] add credential provider --- .../internal/kubeprovider/kubeprovider.go | 198 +++++++++++++ .../kubeprovider/kubeprovider_test.go | 271 ++++++++++++++++++ .../internal/kubeprovider/nsauthz.go | 79 +++++ cmd/credprovider/main.go | 189 ++++++++++++ internal/actorspiffe/actorspiffe.go | 53 ++++ internal/actorspiffe/actorspiffe_test.go | 56 ++++ internal/proto/nsauthzpb/gen.go | 17 ++ internal/proto/nsauthzpb/nsauthz.pb.go | 201 +++++++++++++ internal/proto/nsauthzpb/nsauthz.proto | 36 +++ .../credprovider.yaml | 157 ++++++++++ .../namespace-policy.yaml | 32 +++ .../sample-secret.yaml | 31 ++ pkg/proto/credproviderpb/credprovider.pb.go | 257 +++++++++++++++++ pkg/proto/credproviderpb/credprovider.proto | 56 ++++ .../credproviderpb/credprovider_grpc.pb.go | 149 ++++++++++ pkg/proto/credproviderpb/gen.go | 17 ++ 16 files changed, 1799 insertions(+) create mode 100644 cmd/credprovider/internal/kubeprovider/kubeprovider.go create mode 100644 cmd/credprovider/internal/kubeprovider/kubeprovider_test.go create mode 100644 cmd/credprovider/internal/kubeprovider/nsauthz.go create mode 100644 cmd/credprovider/main.go create mode 100644 internal/actorspiffe/actorspiffe.go create mode 100644 internal/actorspiffe/actorspiffe_test.go create mode 100644 internal/proto/nsauthzpb/gen.go create mode 100644 internal/proto/nsauthzpb/nsauthz.pb.go create mode 100644 internal/proto/nsauthzpb/nsauthz.proto create mode 100644 manifests/egress-credential-injection/credprovider.yaml create mode 100644 manifests/egress-credential-injection/namespace-policy.yaml create mode 100644 manifests/egress-credential-injection/sample-secret.yaml create mode 100644 pkg/proto/credproviderpb/credprovider.pb.go create mode 100644 pkg/proto/credproviderpb/credprovider.proto create mode 100644 pkg/proto/credproviderpb/credprovider_grpc.pb.go create mode 100644 pkg/proto/credproviderpb/gen.go 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/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/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" From 150c6b2bd1e250f19fac97d1194aae7013c2329c Mon Sep 17 00:00:00 2001 From: Yufan Su Date: Tue, 1 Sep 2026 14:32:39 -0700 Subject: [PATCH 2/2] update cred provder --- .../internal/kubeprovider/kubeprovider.go | 40 ++-- .../kubeprovider/kubeprovider_test.go | 46 ++-- .../internal/kubeprovider/nsauthz.go | 42 ++-- cmd/credprovider/main.go | 5 +- internal/proto/nsauthzpb/gen.go | 17 -- internal/proto/nsauthzpb/nsauthz.pb.go | 201 ------------------ internal/proto/nsauthzpb/nsauthz.proto | 36 ---- .../credprovider.yaml | 5 +- .../namespace-policy.yaml | 16 +- .../sample-secret.yaml | 4 +- 10 files changed, 74 insertions(+), 338 deletions(-) delete mode 100644 internal/proto/nsauthzpb/gen.go delete mode 100644 internal/proto/nsauthzpb/nsauthz.pb.go delete mode 100644 internal/proto/nsauthzpb/nsauthz.proto diff --git a/cmd/credprovider/internal/kubeprovider/kubeprovider.go b/cmd/credprovider/internal/kubeprovider/kubeprovider.go index 6f45ec87d4..94dc702b20 100644 --- a/cmd/credprovider/internal/kubeprovider/kubeprovider.go +++ b/cmd/credprovider/internal/kubeprovider/kubeprovider.go @@ -37,8 +37,8 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" ) -// ProviderClass is the substrate-secret:// URI host this backend serves. -const ProviderClass = "kubernetes.io" +// 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" @@ -69,8 +69,8 @@ func ParseURI(raw string) (SecretRef, error) { 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) + 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, "/"), "/") @@ -102,20 +102,15 @@ 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} +// 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. @@ -147,7 +142,7 @@ func (s *Server) RequestSecret(ctx context.Context, req *credproviderpb.RequestS 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) + 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) } @@ -176,23 +171,20 @@ func (s *Server) authorize(ctx context.Context, reqCtx *credproviderpb.SecretReq } // 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 == "" { +// 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 or configure a default", len(data)) + 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[key] + v, ok := data[uriKey] if !ok { - return nil, fmt.Errorf("key %q not present", key) + 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 index d649b82c95..5f224909ff 100644 --- a/cmd/credprovider/internal/kubeprovider/kubeprovider_test.go +++ b/cmd/credprovider/internal/kubeprovider/kubeprovider_test.go @@ -25,7 +25,6 @@ import ( "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" ) @@ -71,15 +70,15 @@ func TestParseURI(t *testing.T) { } } -func TestNewNamespaceAuthorizer(t *testing.T) { - authz, err := NewNamespaceAuthorizer(&nsauthzpb.NamespaceAuthorizationFile{ - Policy: []*nsauthzpb.AtespaceNamespacePolicy{ +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) + t.Fatalf("newNamespaceAuthorizer: %v", err) } tests := []struct { atespace, namespace string @@ -99,19 +98,19 @@ func TestNewNamespaceAuthorizer(t *testing.T) { } // An empty file denies everything. - empty, err := NewNamespaceAuthorizer(&nsauthzpb.NamespaceAuthorizationFile{}) + empty, err := newNamespaceAuthorizer(namespacePolicyFile{}) if err != nil { - t.Fatalf("NewNamespaceAuthorizer(empty): %v", err) + 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"}}}, + if _, err := newNamespaceAuthorizer(namespacePolicyFile{ + Policies: []atespaceNamespacePolicy{{AllowedNamespaces: []string{"ns1"}}}, }); err == nil { - t.Error("NewNamespaceAuthorizer accepted a policy with no atespace, want error") + t.Error("newNamespaceAuthorizer accepted a policy with no atespace, want error") } } @@ -120,11 +119,11 @@ func TestRequestSecretAuthorization(t *testing.T) { 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"}}}, + authz, err := newNamespaceAuthorizer(namespacePolicyFile{ + Policies: []atespaceNamespacePolicy{{Atespace: "team-a", AllowedNamespaces: []string{"ns1"}}}, }) if err != nil { - t.Fatalf("NewNamespaceAuthorizer: %v", err) + 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" @@ -161,7 +160,7 @@ func TestRequestSecretAuthorization(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - srv := NewServer(fake.NewSimpleClientset(secret), "", authz) + 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 { @@ -180,7 +179,7 @@ func TestRequestSecretAuthorization(t *testing.T) { // With no authorizer configured, enforcement is bypassed entirely. t.Run("nil authorizer bypasses", func(t *testing.T) { - srv := NewServer(fake.NewSimpleClientset(secret), "", nil) + 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"}, @@ -206,23 +205,16 @@ func TestRequestSecret(t *testing.T) { } tests := []struct { - name string - defaultKey string - uri string - want string - wantCode codes.Code + 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: "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", @@ -252,7 +244,7 @@ func TestRequestSecret(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := fake.NewSimpleClientset(secret, multiKey) - srv := NewServer(client, tc.defaultKey, nil) + 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 { diff --git a/cmd/credprovider/internal/kubeprovider/nsauthz.go b/cmd/credprovider/internal/kubeprovider/nsauthz.go index 80acd89e29..f644938151 100644 --- a/cmd/credprovider/internal/kubeprovider/nsauthz.go +++ b/cmd/credprovider/internal/kubeprovider/nsauthz.go @@ -18,11 +18,20 @@ import ( "fmt" "os" - "google.golang.org/protobuf/encoding/prototext" - - "github.com/agent-substrate/substrate/internal/proto/nsauthzpb" + "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. @@ -31,35 +40,34 @@ type NamespaceAuthorizer struct { 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. +// 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 nsauthzpb.NamespaceAuthorizationFile - if err := prototext.Unmarshal(data, &file); err != nil { + 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) + 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) { +// 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.GetPolicy() { - if p.GetAtespace() == "" { + for i, p := range file.Policies { + if p.Atespace == "" { return nil, fmt.Errorf("namespace policy %d: atespace is required", i) } - set := allowed[p.GetAtespace()] + set := allowed[p.Atespace] if set == nil { set = make(map[string]struct{}) - allowed[p.GetAtespace()] = set + allowed[p.Atespace] = set } - for _, ns := range p.GetAllowedNamespaces() { + for _, ns := range p.AllowedNamespaces { set[ns] = struct{}{} } } diff --git a/cmd/credprovider/main.go b/cmd/credprovider/main.go index 746055deed..86798ea95b 100644 --- a/cmd/credprovider/main.go +++ b/cmd/credprovider/main.go @@ -53,8 +53,7 @@ var ( 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)") + 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") ) @@ -116,7 +115,7 @@ func run(ctx context.Context) error { } srv := grpc.NewServer(opts...) reflection.Register(srv) - credproviderpb.RegisterCredentialProviderServer(srv, kubeprovider.NewServer(client, *defaultKey, nsAuth)) + credproviderpb.RegisterCredentialProviderServer(srv, kubeprovider.NewServer(client, nsAuth)) lis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", *listenAddr) if err != nil { diff --git a/internal/proto/nsauthzpb/gen.go b/internal/proto/nsauthzpb/gen.go deleted file mode 100644 index 2371d63e0d..0000000000 --- a/internal/proto/nsauthzpb/gen.go +++ /dev/null @@ -1,17 +0,0 @@ -// 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 deleted file mode 100644 index 248615684b..0000000000 --- a/internal/proto/nsauthzpb/nsauthz.pb.go +++ /dev/null @@ -1,201 +0,0 @@ -// 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 deleted file mode 100644 index efdbbec940..0000000000 --- a/internal/proto/nsauthzpb/nsauthz.proto +++ /dev/null @@ -1,36 +0,0 @@ -// 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/credprovider.yaml b/manifests/egress-credential-injection/credprovider.yaml index f6a8f75d1b..3c71af0a1c 100644 --- a/manifests/egress-credential-injection/credprovider.yaml +++ b/manifests/egress-credential-injection/credprovider.yaml @@ -79,11 +79,8 @@ spec: # 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" + - "--namespace-policy-file=/etc/credprovider/namespace-policy.yaml" - "--log-level=info" ports: - name: grpc diff --git a/manifests/egress-credential-injection/namespace-policy.yaml b/manifests/egress-credential-injection/namespace-policy.yaml index bbc6dceb0a..829f8f3301 100644 --- a/manifests/egress-credential-injection/namespace-policy.yaml +++ b/manifests/egress-credential-injection/namespace-policy.yaml @@ -15,18 +15,20 @@ # 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. +# 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.textproto: | + 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). - policy { - atespace: "team-a" - allowed_namespaces: "ns1" - } + policies: + - atespace: team-a + allowedNamespaces: + - ns1 diff --git a/manifests/egress-credential-injection/sample-secret.yaml b/manifests/egress-credential-injection/sample-secret.yaml index 67cd8b263d..8dfe1a438c 100644 --- a/manifests/egress-credential-injection/sample-secret.yaml +++ b/manifests/egress-credential-injection/sample-secret.yaml @@ -14,8 +14,8 @@ # 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. +# 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: