diff --git a/.github/workflows/helm-e2e.yaml b/.github/workflows/helm-e2e.yaml index 4b000ececf..6a7438a89c 100644 --- a/.github/workflows/helm-e2e.yaml +++ b/.github/workflows/helm-e2e.yaml @@ -25,6 +25,8 @@ jobs: env: VERSION: helm-e2e E2E_ATENET_DATAPLANE: agentgateway + E2E_CREDENTIAL_PROVIDER: "1" + E2E_EGRESS_MITM: "1" steps: - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -61,8 +63,8 @@ jobs: # the chart composes {registry}/{repository}/{component}, so the local # registry serves each image where the default repository expects it -- # the same path-preserving rule a production mirror follows. - for component in ateapi atecontroller atelet podcertcontroller atenet; do - KO_DOCKER_REPO="localhost:5001/kagent-dev/substrate/${component}" \ + for component in ateapi atecontroller atelet podcertcontroller atenet credential-provider/kubernetes-secrets; do + KO_DOCKER_REPO="localhost:5001/kagent-dev/substrate/${component##*/}" \ ./hack/run-tool.sh ko build --bare --tags helm-e2e \ --platform linux/amd64 "./cmd/${component}" done @@ -72,6 +74,7 @@ jobs: helm upgrade --install substrate charts/substrate \ --namespace ate-system \ --create-namespace \ + -f internal/e2e/suites/credentials/values.yaml \ --set image.registry=localhost:5001 \ --set image.tag=helm-e2e \ --set 'atelet.extraArgs[0]=--localhost-registry-replacement=kind-registry:5000' \ @@ -84,6 +87,7 @@ jobs: hack/install-ate-kind.sh --create-actor-id-ca-pool-secret hack/install-ate-kind.sh --create-actor-id-ca-certs-secret hack/install-ate-kind.sh --create-api-authentication-config + hack/install-ate-kind.sh --create-egress-mitm-ca-pool-secret - name: Wait for Helm install run: | helm upgrade substrate charts/substrate \ @@ -104,7 +108,7 @@ jobs: - name: Deploy gVisor counter demo run: hack/install-ate-kind.sh --deploy-demo-counter - name: Deploy egress demo - run: hack/install-ate-kind.sh --deploy-demo-egress + run: hack/install-ate-kind.sh --deploy-demo-egress-mitm - name: Run E2E tests (gVisor) run: hack/run-e2e-kind.sh -v -args --no-color - name: Run E2E tests (micro-VM) diff --git a/Makefile b/Makefile index 11307e18d4..b04750a7dc 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,7 @@ CONTROL_PLANE_IMAGES := ./cmd/ateapi \ ./cmd/atecontroller \ ./cmd/atelet \ ./cmd/atenet \ + ./cmd/credential-provider/kubernetes-secrets \ ./cmd/podcertcontroller WORKER_IMAGES := ./cmd/ateom-gvisor \ ./cmd/ateom-microvm diff --git a/charts/substrate/README.md b/charts/substrate/README.md index 8a78d396fc..2803a5aa63 100644 --- a/charts/substrate/README.md +++ b/charts/substrate/README.md @@ -19,6 +19,11 @@ By default, component images are pulled from `ghcr.io/kagent-dev/substrate` using the chart `appVersion` as the tag. Override `image.registry` and `image.tag` to install from a different image repository or tag. +The chart installs the Kubernetes credential provider and enables HTTPS egress +interception. Create the `egress-mitm-ca-pool` Secret and configure actor trust +as described in the [credential provider setup](../../docs/kubernetes-credential-provider.md). +Namespace grants default to an empty list, denying credential access. + ## Render manifests without applying ```bash @@ -42,6 +47,7 @@ See `values.yaml` for the full set; the important keys: | `rustfs.enabled` | `true` | Deploy an in-cluster S3-compatible RustFS bucket for snapshots | | `atelet.storageBackend` | `s3` | Default snapshot backend, wired to RustFS when `rustfs.enabled=true` | | `atelet.gcpAuthForImagePulls` | `false` | Enable only when using GCP registry auth | +| `credentialProvider.namespacePolicies` | `[]` | Default-deny atespace-to-namespace grants; the chart includes get-only Secret RBAC for the provider | | `ateApi.extraArgs` | `[]` | Additional command-line arguments appended to the ateapi defaults | | `otel.endpoint` | `""` | Set to an OTLP endpoint to export traces, metrics and the router access log | | `otel.traces.enabled` | `true` | Set to `false` to export no traces from the router; the Go components do not honor this yet | diff --git a/charts/substrate/templates/atenet-egress.yaml b/charts/substrate/templates/atenet-egress.yaml index 2e78861851..3ae521e96d 100644 --- a/charts/substrate/templates/atenet-egress.yaml +++ b/charts/substrate/templates/atenet-egress.yaml @@ -56,12 +56,33 @@ data: - mode: internal protocol: AUTO listeners: - - protocol: TLS - hostname: "*" - tcpRoutes: + - protocol: HTTPS + tls: + mode: dynamicCa + cert: /run/egress-mitm/tls.crt + key: /run/egress-mitm/tls.key + routes: - backends: - - dynamic: - target: source.connectHeaders["host"] + - dynamic: {} + policies: + backendTLS: {} + policies: + substrateEgress: + host: {{ include "substrate.fullname" (list "api" .) }}.{{ .Release.Namespace }}.svc:443 + policies: + backendTLS: + cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem + key: /run/podidentity.podcert.ate.dev/credential-bundle.pem + root: /run/servicedns.podcert.ate.dev/trust-bundle.pem + credentialProviders: + - uriAuthority: kubernetes.io + target: + host: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }}.{{ .Release.Namespace }}.svc:50051 + policies: + backendTLS: + cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem + key: /run/podidentity.podcert.ate.dev/credential-bundle.pem + root: /run/servicedns.podcert.ate.dev/trust-bundle.pem - protocol: HTTP routes: - backends: @@ -75,6 +96,15 @@ data: cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem key: /run/podidentity.podcert.ate.dev/credential-bundle.pem root: /run/servicedns.podcert.ate.dev/trust-bundle.pem + credentialProviders: + - uriAuthority: kubernetes.io + target: + host: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }}.{{ .Release.Namespace }}.svc:50051 + policies: + backendTLS: + cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem + key: /run/podidentity.podcert.ate.dev/credential-bundle.pem + root: /run/servicedns.podcert.ate.dev/trust-bundle.pem - protocol: TCP tcpRoutes: - backends: @@ -133,6 +163,9 @@ spec: port: readiness periodSeconds: 1 volumeMounts: + - name: egress-mitm + mountPath: /run/egress-mitm + readOnly: true - name: config mountPath: /etc/agentgateway readOnly: true @@ -194,6 +227,14 @@ spec: - name: drain-signal mountPath: /var/run/atenet volumes: + - name: egress-mitm + secret: + secretName: egress-mitm-ca-pool + items: + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key - name: config configMap: name: {{ include "substrate.fullname" (list "atenet-egress-agentgateway-config" .) }} diff --git a/charts/substrate/templates/k8s-credential-provider.yaml b/charts/substrate/templates/k8s-credential-provider.yaml new file mode 100644 index 0000000000..753cbc9511 --- /dev/null +++ b/charts/substrate/templates/k8s-credential-provider.yaml @@ -0,0 +1,167 @@ +{{/* +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 ate-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: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + namespace: {{ .Release.Namespace }} +--- +# The provider checks the actor's atespace-to-namespace grant before reading. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "substrate.fullname" (list "k8s-credential-provider-secret-reader" .) }} +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "substrate.fullname" (list "k8s-credential-provider-secret-reader" .) }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "substrate.fullname" (list "k8s-credential-provider-secret-reader" .) }} +subjects: +- kind: ServiceAccount + name: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + template: + metadata: + annotations: + checksum/namespace-policy: {{ toJson .Values.credentialProvider.namespacePolicies | sha256sum }} + labels: + app: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + spec: + serviceAccountName: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + {{- with include "substrate.imagePullSecrets" . }}{{- . | nindent 6 }}{{- end }} + securityContext: + runAsUser: 65532 + runAsGroup: 65532 + runAsNonRoot: true + containers: + - name: k8s-credential-provider + image: {{ include "substrate.componentImage" (list "kubernetes-secrets" .) }} + imagePullPolicy: {{ include "substrate.imagePullPolicy" . }} + args: + - "--listen-address=:50051" + - "--metrics-address=:9090" + # Use this Service's DNS certificate; only the egress injector may call. + - "--server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem" + - "--client-ca-file=/run/podidentity.podcert.ate.dev/trust-bundle.pem" + # Enforce the atespace→namespace authorization policy (default-deny). + - "--namespace-policy-file=/etc/k8s-credential-provider/namespace-policy.yaml" + - "--injector-spiffe-id=spiffe://cluster.local/ns/{{ .Release.Namespace }}/sa/{{ include "substrate.fullname" (list "atenet-egress" .) }}" + - "--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/k8s-credential-provider + 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: {{ include "substrate.fullname" (list "k8s-credential-provider-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: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + namespace: {{ .Release.Namespace }} +spec: + type: ClusterIP + selector: + app: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + ports: + - name: grpc + port: 50051 + targetPort: grpc + protocol: TCP +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "substrate.fullname" (list "k8s-credential-provider-namespace-policy" .) }} + namespace: {{ .Release.Namespace }} +data: + namespace-policy.yaml: | + policies: {{ toJson .Values.credentialProvider.namespacePolicies }} diff --git a/charts/substrate/values.yaml b/charts/substrate/values.yaml index f598567119..fef293a355 100644 --- a/charts/substrate/values.yaml +++ b/charts/substrate/values.yaml @@ -58,6 +58,13 @@ atelet: ateApi: extraArgs: [] +# Kubernetes Secret provider and AGW HTTP/HTTPS credential injection. +# Includes get-only Secret RBAC. HTTPS requires egress-mitm-ca-pool and actor trust. +credentialProvider: + namespacePolicies: [] + # - atespace: team-a + # allowedNamespaces: [team-a-secrets] + # Name of a ConfigMap in the release namespace that supplies per-environment # overrides for ate-api-server (ATE_API_POSTGRES_CONNECTION_STRING, ...). # Mounted via envFrom with optional=true. Created by the chart from these values. @@ -117,5 +124,5 @@ images: postgres: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 rustfs: rustfs/rustfs:1.0.0-beta.3@sha256:378642b05b7dcb4849fb77ebe6aca4ced1c3f66e7e504247df95a5c9018d3358 awsCli: amazon/aws-cli:2.17.0@sha256:643507c10ada7964ca6157b3d799f030b90577643da9955d319a77399ed80d73 - agentgateway: ghcr.io/agentgateway/agentgateway:v0.0.0-alpha.9f9744cf + agentgateway: ghcr.io/agentgateway/agentgateway:v0.0.0-alpha.8dba3989@sha256:fdde26d4b0ea11d3e740dc19905dfe9b26e88f40fa8b9985f94ed1d8420e389e busybox: busybox:1.36 diff --git a/cmd/ate-setup/internal/images/images.go b/cmd/ate-setup/internal/images/images.go index 2b1ed1c628..f462addf1f 100644 --- a/cmd/ate-setup/internal/images/images.go +++ b/cmd/ate-setup/internal/images/images.go @@ -44,6 +44,7 @@ var Components = []string{ "cmd/atecontroller", "cmd/atelet", "cmd/atenet", + "cmd/credential-provider/kubernetes-secrets", "cmd/ateom-gvisor", "cmd/ateom-microvm", "cmd/podcertcontroller", diff --git a/cmd/credential-provider/kubernetes-secrets/kubeprovider.go b/cmd/credential-provider/kubernetes-secrets/kubeprovider.go new file mode 100644 index 0000000000..3d07ae2866 --- /dev/null +++ b/cmd/credential-provider/kubernetes-secrets/kubeprovider.go @@ -0,0 +1,186 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This file implements the CredentialProvider plugin API backed by Kubernetes +// Secrets. It resolves ate-secret:// URIs of the provider "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 main + +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/apimachinery/pkg/util/validation" + "k8s.io/client-go/kubernetes" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +// ProviderName is the ate-secret:// URI host this backend serves. +const ProviderName = "kubernetes.io" + +// uriScheme is the only scheme a credential URI may carry. +const uriScheme = "ate-secret" + +// SecretRef is a parsed ate-secret:// URI for the kubernetes.io provider. +// +// ate-secret://kubernetes.io//[/] +type SecretRef struct { + Namespace string + Name string + // Key is the data key within the Secret, or "" when the URI omits it (only + // allowed when the secret contains one entry). + Key string +} + +// ParseURI parses a ate-secret:// URI of the kubernetes.io provider. It +// rejects any other scheme or provider name. +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("malformed credential URI %q: scheme is %q, want %q", raw, u.Scheme, uriScheme) + } + if u.Host != ProviderName { + return SecretRef{}, fmt.Errorf("credential URI %q: provider is %q, this provider serves %q", raw, u.Host, ProviderName) + } + + if u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(raw, "#") { + return SecretRef{}, fmt.Errorf("credential URI must not contain user info, a query, or a fragment") + } + + segments := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") + // / is the minimum; an optional 3rd segment is the data + // key. + if len(segments) < 2 || len(segments) > 3 { + 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{ + Namespace: segments[0], + Name: segments[1], + } + if len(segments) == 3 { + ref.Key = segments[2] + } + if len(validation.IsDNS1123Label(ref.Namespace)) != 0 || len(validation.IsDNS1123Subdomain(ref.Name)) != 0 || (ref.Key != "" && len(validation.IsConfigMapKey(ref.Key)) != 0) { + return SecretRef{}, fmt.Errorf("credential URI contains an invalid namespace, secret name, or key") + } + return ref, nil +} + +// Server implements credproviderpb.CredentialProviderServer over the Kubernetes +// API. +type Server struct { + credproviderpb.UnimplementedCredentialProviderServer + + client kubernetes.Interface + // nsAuth restricts which namespaces an atespace may resolve secrets from. + nsAuth *NamespaceAuthorizer +} + +// NewServer builds a Kubernetes credential provider with a default-deny policy. +func NewServer(client kubernetes.Interface, nsAuth *NamespaceAuthorizer) *Server { + return &Server{client: client, nsAuth: nsAuth} +} + +// FetchSecret resolves one ate-secret:// URI to its Secret value. +func (s *Server) FetchSecret(ctx context.Context, req *credproviderpb.FetchSecretRequest) (*credproviderpb.FetchSecretResponse, error) { + ref, err := ParseURI(req.GetUri()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + if err := s.authorize(ctx, req.GetActorSpiffeId(), ref.Namespace); err != nil { + return nil, err + } + + slog.InfoContext(ctx, "resolving credential", + slog.String("provider", ProviderName), + slog.String("namespace", ref.Namespace), + slog.String("secret", ref.Name), + slog.String("actor", req.GetActorSpiffeId()), + ) + + 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.Error(codes.Unavailable, "could not read secret from Kubernetes") + } + + value, err := selectKey(secret.Data, ref.Key) + if err != nil { + return nil, status.Errorf(codes.NotFound, "secret %s/%s: %v", ref.Namespace, ref.Name, err) + } + return &credproviderpb.FetchSecretResponse{OpaqueBytes: value}, nil +} + +// authorize enforces the atespace→namespace policy. It derives the atespace from +// the attested actor SPIFFE ID and denies unless the URI's namespace is in that +// atespace's allowed list. +func (s *Server) authorize(ctx context.Context, actorSpiffeID, namespace string) error { + actor, err := resources.ActorRefFromSPIFFEID(actorSpiffeID) + 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(actor.Atespace, namespace) { + slog.WarnContext(ctx, "credential request denied: atespace not permitted for namespace", + slog.String("atespace", actor.Atespace), slog.String("namespace", namespace)) + return status.Errorf(codes.PermissionDenied, "atespace %q is not permitted to resolve secrets in namespace %q", actor.Atespace, namespace) + } + return nil +} + +// selectKey resolves which Secret data entry to return: the URI's explicit key, +// else the sole key of a single-key Secret. A URI without a key resolving a +// multi-key Secret is an error. +func selectKey(data map[string][]byte, uriKey string) ([]byte, error) { + if uriKey == "" { + if len(data) != 1 { + return nil, fmt.Errorf("no key given and the secret has %d keys; specify one in the URI", len(data)) + } + for _, v := range data { + return v, nil + } + } + v, ok := data[uriKey] + if !ok { + return nil, fmt.Errorf("key %q not present", uriKey) + } + return v, nil +} diff --git a/cmd/credential-provider/kubernetes-secrets/kubeprovider_test.go b/cmd/credential-provider/kubernetes-secrets/kubeprovider_test.go new file mode 100644 index 0000000000..f4832714dc --- /dev/null +++ b/cmd/credential-provider/kubernetes-secrets/kubeprovider_test.go @@ -0,0 +1,363 @@ +// 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 main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +func TestParseURI(t *testing.T) { + tests := []struct { + name string + uri string + want SecretRef + wantErr bool + }{ + { + name: "with key", + uri: "ate-secret://kubernetes.io/ns1/example-api/token", + want: SecretRef{Namespace: "ns1", Name: "example-api", Key: "token"}, + }, + { + name: "without key", + uri: "ate-secret://kubernetes.io/ns1/example-api", + want: SecretRef{Namespace: "ns1", Name: "example-api"}, + }, + {name: "wrong scheme", uri: "https://kubernetes.io/ns1/example-api", wantErr: true}, + {name: "wrong provider", uri: "ate-secret://vault.io/ns1/example-api", wantErr: true}, + {name: "too few segments", uri: "ate-secret://kubernetes.io/ns1", wantErr: true}, + {name: "too many segments", uri: "ate-secret://kubernetes.io/a/b/c/d", wantErr: true}, + {name: "user info", uri: "ate-secret://user@kubernetes.io/ns1/api/token", wantErr: true}, + {name: "query", uri: "ate-secret://kubernetes.io/ns1/api?key=token", wantErr: true}, + {name: "empty query", uri: "ate-secret://kubernetes.io/ns1/api?", wantErr: true}, + {name: "fragment", uri: "ate-secret://kubernetes.io/ns1/api#token", wantErr: true}, + {name: "empty fragment", uri: "ate-secret://kubernetes.io/ns1/api#", wantErr: true}, + {name: "trailing slash", uri: "ate-secret://kubernetes.io/ns1/api/", wantErr: true}, + {name: "empty namespace", uri: "ate-secret://kubernetes.io//api/token", wantErr: true}, + {name: "invalid namespace", uri: "ate-secret://kubernetes.io/NS/api/token", wantErr: true}, + {name: "path traversal", uri: "ate-secret://kubernetes.io/ns1/../token", wantErr: true}, + {name: "encoded slash in key", uri: "ate-secret://kubernetes.io/ns1/api/a%2Fb", wantErr: true}, + {name: "invalid key", uri: "ate-secret://kubernetes.io/ns1/api/key%20name", wantErr: true}, + {name: "unparseable", uri: "://://", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseURI(tc.uri) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseURI(%q) = %+v, want error", tc.uri, got) + } + return + } + if err != nil { + t.Fatalf("ParseURI(%q) unexpected error: %v", tc.uri, err) + } + if got != tc.want { + t.Errorf("ParseURI(%q) = %+v, want %+v", tc.uri, got, tc.want) + } + }) + } +} + +func TestNamespaceAuthorizer(t *testing.T) { + authz, err := newNamespaceAuthorizer(namespacePolicyFile{ + Policies: []atespaceNamespacePolicy{ + {Atespace: "team-a", AllowedNamespaces: []string{"ns1", "shared"}}, + {Atespace: "team-b", AllowedNamespaces: []string{"ns2"}}, + }, + }) + if err != nil { + t.Fatalf("newNamespaceAuthorizer: %v", err) + } + tests := []struct { + atespace, namespace string + want bool + }{ + {"team-a", "ns1", true}, + {"team-a", "shared", true}, + {"team-a", "ns2", false}, // namespace not in team-a's list + {"team-b", "ns2", true}, // team-b's own namespace + {"team-c", "ns1", false}, // atespace absent -> default deny + {"team-a", "", false}, // empty namespace + } + for _, tc := range tests { + if got := authz.Allowed(tc.atespace, tc.namespace); got != tc.want { + t.Errorf("Allowed(%q, %q) = %v, want %v", tc.atespace, tc.namespace, got, tc.want) + } + } + + // An empty file denies everything. + empty, err := newNamespaceAuthorizer(namespacePolicyFile{}) + if err != nil { + t.Fatalf("newNamespaceAuthorizer(empty): %v", err) + } + if empty.Allowed("team-a", "ns1") { + t.Error("empty authorizer allowed team-a/ns1, want deny") + } + + // A policy without an atespace is rejected. + if _, err := newNamespaceAuthorizer(namespacePolicyFile{ + Policies: []atespaceNamespacePolicy{{AllowedNamespaces: []string{"ns1"}}}, + }); err == nil { + t.Error("newNamespaceAuthorizer accepted a policy with no atespace, want error") + } +} + +func TestFetchSecretAuthorization(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "example-api", Namespace: "ns1"}, + Data: map[string][]byte{"token": []byte("s3cr3t")}, + } + authz, err := newNamespaceAuthorizer(namespacePolicyFile{ + Policies: []atespaceNamespacePolicy{{Atespace: "team-a", AllowedNamespaces: []string{"ns1"}}}, + }) + if err != nil { + t.Fatalf("newNamespaceAuthorizer: %v", err) + } + const teamAURI = "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor" + const teamBURI = "spiffe://substrate-actor.local/atespace/team-b/actor/my-actor" + + tests := []struct { + name string + actorSpiffeID string + uri string + wantCode codes.Code + }{ + { + name: "allowed", + actorSpiffeID: teamAURI, + uri: "ate-secret://kubernetes.io/ns1/example-api/token", + }, + { + name: "namespace not permitted", + actorSpiffeID: teamAURI, + uri: "ate-secret://kubernetes.io/ns2/example-api/token", + wantCode: codes.PermissionDenied, + }, + { + name: "unknown atespace", + actorSpiffeID: teamBURI, + uri: "ate-secret://kubernetes.io/ns1/example-api/token", + wantCode: codes.PermissionDenied, + }, + { + name: "garbage identity", + actorSpiffeID: "not-a-spiffe-uri", + uri: "ate-secret://kubernetes.io/ns1/example-api/token", + wantCode: codes.PermissionDenied, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := fake.NewSimpleClientset(secret) + srv := NewServer(client, authz) + resp, err := srv.FetchSecret(context.Background(), &credproviderpb.FetchSecretRequest{Uri: tc.uri, ActorSpiffeId: tc.actorSpiffeID}) + if tc.wantCode != codes.OK { + if len(client.Actions()) != 0 { + t.Fatal("denied request reached Kubernetes") + } + 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.GetOpaqueBytes()); got != "s3cr3t" { + t.Errorf("secret = %q, want s3cr3t", got) + } + }) + } + + // A missing authorizer must fail closed. + t.Run("nil authorizer denies", func(t *testing.T) { + srv := NewServer(fake.NewSimpleClientset(secret), nil) + if _, err := srv.FetchSecret(context.Background(), &credproviderpb.FetchSecretRequest{ + Uri: "ate-secret://kubernetes.io/ns1/example-api/token", + ActorSpiffeId: teamAURI, + }); status.Code(err) != codes.PermissionDenied { + t.Fatalf("nil authorizer should deny, got %v", err) + } + }) +} + +func TestFetchSecret(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "example-api", Namespace: "ns1"}, + Data: map[string][]byte{ + "token": []byte("s3cr3t"), + }, + } + multiKey := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "multi", Namespace: "ns1"}, + Data: map[string][]byte{ + "a": []byte("aa"), + "b": []byte("bb"), + }, + } + + tests := []struct { + name string + uri string + want string + wantCode codes.Code + }{ + { + name: "explicit key", + uri: "ate-secret://kubernetes.io/ns1/example-api/token", + want: "s3cr3t", + }, + { + name: "single-key fallback", + uri: "ate-secret://kubernetes.io/ns1/example-api", + want: "s3cr3t", + }, + { + name: "no key, multiple keys", + uri: "ate-secret://kubernetes.io/ns1/multi", + wantCode: codes.NotFound, + }, + { + name: "missing key", + uri: "ate-secret://kubernetes.io/ns1/example-api/nope", + wantCode: codes.NotFound, + }, + { + name: "secret not found", + uri: "ate-secret://kubernetes.io/ns1/absent/token", + wantCode: codes.NotFound, + }, + { + name: "bad uri", + uri: "ate-secret://vault.io/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, &NamespaceAuthorizer{allowed: map[string]map[string]struct{}{"team-a": {"ns1": {}}}}) + resp, err := srv.FetchSecret(context.Background(), &credproviderpb.FetchSecretRequest{Uri: tc.uri, ActorSpiffeId: "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor"}) + if tc.wantCode != codes.OK { + if status.Code(err) != tc.wantCode { + t.Fatalf("FetchSecret(%q) code = %v, want %v (err=%v)", tc.uri, status.Code(err), tc.wantCode, err) + } + return + } + if err != nil { + t.Fatalf("FetchSecret(%q) unexpected error: %v", tc.uri, err) + } + if got := string(resp.GetOpaqueBytes()); got != tc.want { + t.Errorf("FetchSecret(%q) = %q, want %q", tc.uri, got, tc.want) + } + }) + } +} + +func TestLoadNamespaceAuthorizer(t *testing.T) { + for _, tc := range []struct { + name, policy string + wantErr bool + }{ + {name: "valid", policy: "policies:\n- atespace: team-a\n allowedNamespaces: [ns1]\n"}, + {name: "empty", policy: "policies: []"}, + {name: "unknown field", policy: "polices: []", wantErr: true}, + {name: "duplicate field", policy: "policies: []\npolicies: []", wantErr: true}, + {name: "missing atespace", policy: "policies: [{allowedNamespaces: [ns1]}]", wantErr: true}, + {name: "invalid namespace", policy: "policies: [{atespace: team-a, allowedNamespaces: ['*']}]", wantErr: true}, + {name: "malformed", policy: "policies: [", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "policy.yaml") + if err := os.WriteFile(path, []byte(tc.policy), 0600); err != nil { + t.Fatal(err) + } + auth, err := LoadNamespaceAuthorizer(path) + if (err != nil) != tc.wantErr { + t.Fatalf("LoadNamespaceAuthorizer: %v", err) + } + if err == nil && auth.Allowed("team-a", "ns1") != (tc.name == "valid") { + t.Fatal("unexpected namespace grant") + } + }) + } + if _, err := LoadNamespaceAuthorizer(filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("missing policy accepted") + } +} + +func TestFetchSecretKubernetesErrors(t *testing.T) { + for _, tc := range []struct { + name string + err error + code codes.Code + }{ + {"forbidden", k8serrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, "api", errors.New("RBAC")), codes.PermissionDenied}, + {"unavailable", errors.New("upstream response body should stay private"), codes.Unavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("get", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) { return true, nil, tc.err }) + srv := NewServer(client, &NamespaceAuthorizer{allowed: map[string]map[string]struct{}{"team-a": {"ns1": {}}}}) + _, err := srv.FetchSecret(t.Context(), &credproviderpb.FetchSecretRequest{ + Uri: "ate-secret://kubernetes.io/ns1/api/token", ActorSpiffeId: "spiffe://substrate-actor.local/atespace/team-a/actor/a", + }) + if status.Code(err) != tc.code { + t.Fatalf("FetchSecret: %v, want %v", err, tc.code) + } + if strings.Contains(err.Error(), "stay private") { + t.Fatal("Kubernetes response body exposed") + } + }) + } +} + +func TestFetchSecretObservesRotation(t *testing.T) { + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "ns1"}, Data: map[string][]byte{"token": []byte("first")}} + client := fake.NewSimpleClientset(secret) + srv := NewServer(client, &NamespaceAuthorizer{allowed: map[string]map[string]struct{}{"team-a": {"ns1": {}}}}) + req := &credproviderpb.FetchSecretRequest{Uri: "ate-secret://kubernetes.io/ns1/api/token", ActorSpiffeId: "spiffe://substrate-actor.local/atespace/team-a/actor/a"} + first, err := srv.FetchSecret(t.Context(), req) + if err != nil || string(first.GetOpaqueBytes()) != "first" { + t.Fatalf("first fetch: %v, %v", first, err) + } + secret.Data["token"] = []byte("rotated") + if _, err := client.CoreV1().Secrets("ns1").Update(t.Context(), secret, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + next, err := srv.FetchSecret(t.Context(), req) + if err != nil || string(next.GetOpaqueBytes()) != "rotated" { + t.Fatalf("fetch after rotation: %v, %v", next, err) + } +} diff --git a/cmd/credential-provider/kubernetes-secrets/main.go b/cmd/credential-provider/kubernetes-secrets/main.go new file mode 100644 index 0000000000..1d4d8dccd2 --- /dev/null +++ b/cmd/credential-provider/kubernetes-secrets/main.go @@ -0,0 +1,213 @@ +// 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 kubernetes-secrets is the Kubernetes-Secrets credential-provider +// plugin: a gRPC service that resolves ate-secret:// URIs of the kubernetes.io +// provider 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" + "net/url" + "os" + "os/signal" + "strings" + "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/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 ( + injectorSPIFFEID = pflag.String("injector-spiffe-id", "spiffe://cluster.local/ns/ate-system/sa/atenet-egress", "SPIFFE identity of the egress injector allowed to fetch credentials") + 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 (required)") + clientCAFile = pflag.String("client-ca-file", "", "CA bundle that caller (injector) client certificates must chain to (required)") + nsPolicyFile = pflag.String("namespace-policy-file", "", "path to the atespace→namespace authorization YAML (required)") + 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) + } + + if *nsPolicyFile == "" { + return fmt.Errorf("--namespace-policy-file is required") + } + + nsAuth, err := LoadNamespaceAuthorizer(*nsPolicyFile) + if err != nil { + return fmt.Errorf("namespace policy: %w", err) + } + slog.InfoContext(ctx, "loaded namespace authorization policy", slog.String("file", *nsPolicyFile)) + + creds, err := buildServerCreds(ctx) + if err != nil { + return fmt.Errorf("server credentials: %w", err) + } + + srv := grpc.NewServer( + grpc.StatsHandler(otelgrpc.NewServerHandler()), + grpc.Creds(creds), + ) + reflection.Register(srv) + credproviderpb.RegisterCredentialProviderServer(srv, NewServer(client, nsAuth)) + + lis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", *listenAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", *listenAddr, err) + } + + shutdownCtx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer stop() + go func() { + <-shutdownCtx.Done() + slog.Info("shutting down") + readiness.MarkNotReady() + done := make(chan struct{}) + go func() { + srv.GracefulStop() + close(done) + }() + select { + case <-done: + case <-time.After(*drainGrace): + slog.Warn("graceful shutdown timed out; forcing stop", slog.Duration("grace", *drainGrace)) + srv.Stop() + } + }() + + slog.InfoContext(ctx, "credprovider listening", slog.String("address", lis.Addr().String())) + 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 the mutual-TLS credentials the provider serves with: +// it presents the credential bundle to callers and requires each caller to +// present a certificate that both chains to --client-ca-file and carries the +// injector's SAN. Both --server-cred-bundle and --client-ca-file are required. +func buildServerCreds(ctx context.Context) (credentials.TransportCredentials, error) { + if *serverBundle == "" { + return nil, fmt.Errorf("--server-cred-bundle is required") + } + if *clientCAFile == "" { + return nil, fmt.Errorf("--client-ca-file is required") + } + + id, err := url.Parse(*injectorSPIFFEID) + if err != nil || id.Scheme != "spiffe" || id.Host == "" || id.Path == "" || id.User != nil || id.RawQuery != "" || id.ForceQuery || strings.Contains(*injectorSPIFFEID, "#") { + return nil, fmt.Errorf("--injector-spiffe-id must be a SPIFFE URI") + } + + 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 := &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: credbundle.Loader(*serverBundle), + // Require a client certificate that chains to the trust bundle. + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: pool, + VerifyConnection: verifyClientSAN(*injectorSPIFFEID), + } + slog.InfoContext(ctx, "verifying caller client certificates", + slog.String("ca", *clientCAFile), slog.String("required_san", *injectorSPIFFEID)) + return credentials.NewTLS(cfg), nil +} + +// verifyClientSAN returns a TLS VerifyConnection callback that accepts a caller +// only when its certificate carries expectedSAN as a URI SAN. +func verifyClientSAN(expectedSAN string) func(tls.ConnectionState) error { + return func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("client certificate is required") + } + leaf := state.PeerCertificates[0] + for _, u := range leaf.URIs { + if u.String() == expectedSAN { + return nil + } + } + return fmt.Errorf("client certificate URI SANs %v do not include the expected injector identity %q", leaf.URIs, expectedSAN) + } +} diff --git a/cmd/credential-provider/kubernetes-secrets/main_test.go b/cmd/credential-provider/kubernetes-secrets/main_test.go new file mode 100644 index 0000000000..483861f95d --- /dev/null +++ b/cmd/credential-provider/kubernetes-secrets/main_test.go @@ -0,0 +1,211 @@ +// 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 main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "math/big" + "net" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/localca" + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func certWithURIs(t *testing.T, uris ...string) *x509.Certificate { + t.Helper() + cert := &x509.Certificate{} + for _, u := range uris { + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("parsing SAN %q: %v", u, err) + } + cert.URIs = append(cert.URIs, parsed) + } + return cert +} + +func TestVerifyClientSAN(t *testing.T) { + injector := *injectorSPIFFEID + + tests := []struct { + name string + state tls.ConnectionState + wantErr bool + }{ + { + name: "matching SAN", + state: tls.ConnectionState{PeerCertificates: []*x509.Certificate{certWithURIs(t, injector)}}, + }, + { + name: "matching SAN among several", + state: tls.ConnectionState{PeerCertificates: []*x509.Certificate{certWithURIs(t, "spiffe://cluster.local/ns/other/sa/x", injector)}}, + }, + { + name: "wrong SAN", + state: tls.ConnectionState{PeerCertificates: []*x509.Certificate{certWithURIs(t, "spiffe://cluster.local/ns/ate-system/sa/impostor")}}, + wantErr: true, + }, + { + name: "no URI SANs", + state: tls.ConnectionState{PeerCertificates: []*x509.Certificate{certWithURIs(t)}}, + wantErr: true, + }, + { + name: "no peer certificate", + state: tls.ConnectionState{}, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := verifyClientSAN(injector)(tc.state) + if tc.wantErr && err == nil { + t.Fatal("expected an error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestProviderMTLS(t *testing.T) { + ca, err := localca.GenerateCA("trusted", localca.KeyTypeECDSAP256, time.Hour) + if err != nil { + t.Fatal(err) + } + untrustedCA, err := localca.GenerateCA("untrusted", localca.KeyTypeECDSAP256, time.Hour) + if err != nil { + t.Fatal(err) + } + servingCert := issueCertificate(t, ca, "") + dir := t.TempDir() + oldBundle, oldCAFile, oldInjector := *serverBundle, *clientCAFile, *injectorSPIFFEID + t.Cleanup(func() { *serverBundle, *clientCAFile, *injectorSPIFFEID = oldBundle, oldCAFile, oldInjector }) + *serverBundle, *clientCAFile = filepath.Join(dir, "server.pem"), filepath.Join(dir, "ca.pem") + *injectorSPIFFEID = "spiffe://cluster.local/ns/custom/sa/release-atenet-egress" + key, err := x509.MarshalPKCS8PrivateKey(servingCert.PrivateKey) + if err != nil { + t.Fatal(err) + } + bundle := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}) + bundle = append(bundle, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: servingCert.Certificate[0]})...) + if err := os.WriteFile(*serverBundle, bundle, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(*clientCAFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.RootCertificate.Raw}), 0600); err != nil { + t.Fatal(err) + } + creds, err := buildServerCreds(t.Context()) + if err != nil { + t.Fatal(err) + } + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "ns1"}, Data: map[string][]byte{"token": []byte("credential")}, + }) + srv := grpc.NewServer(grpc.Creds(creds)) + credproviderpb.RegisterCredentialProviderServer(srv, NewServer(client, &NamespaceAuthorizer{allowed: map[string]map[string]struct{}{"team-a": {"ns1": {}}}})) + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + roots := x509.NewCertPool() + roots.AddCert(ca.RootCertificate) + for _, tc := range []struct { + name string + certs []tls.Certificate + allowed bool + }{ + {"injector", []tls.Certificate{issueCertificate(t, ca, *injectorSPIFFEID)}, true}, + {"other workload", []tls.Certificate{issueCertificate(t, ca, "spiffe://cluster.local/ns/custom/sa/other")}, false}, + {"missing certificate", nil, false}, + {"untrusted injector", []tls.Certificate{issueCertificate(t, untrustedCA, *injectorSPIFFEID)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + RootCAs: roots, ServerName: "api.ate-system.svc", Certificates: tc.certs, MinVersion: tls.VersionTLS13, + }))) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + before := len(client.Actions()) + resp, err := credproviderpb.NewCredentialProviderClient(conn).FetchSecret(ctx, &credproviderpb.FetchSecretRequest{ + Uri: "ate-secret://kubernetes.io/ns1/api/token", ActorSpiffeId: "spiffe://substrate-actor.local/atespace/team-a/actor/a", + }) + if tc.allowed { + if err != nil || string(resp.GetOpaqueBytes()) != "credential" { + t.Fatalf("FetchSecret: %v, %v", resp, err) + } + } else { + if err == nil { + t.Fatal("unauthorized peer received credentials") + } + if len(client.Actions()) != before { + t.Fatal("unauthorized peer reached Kubernetes") + } + } + }) + } +} + +func issueCertificate(t *testing.T, ca *localca.CA, uri string) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: serial, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"api.ate-system.svc", "localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + } + if uri != "" { + parsed, err := url.Parse(uri) + if err != nil { + t.Fatal(err) + } + template.URIs = []*url.URL{parsed} + } + der, err := x509.CreateCertificate(rand.Reader, template, ca.RootCertificate, &key.PublicKey, ca.SigningKey) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} diff --git a/cmd/credential-provider/kubernetes-secrets/manifests_test.go b/cmd/credential-provider/kubernetes-secrets/manifests_test.go new file mode 100644 index 0000000000..75a99bb6c0 --- /dev/null +++ b/cmd/credential-provider/kubernetes-secrets/manifests_test.go @@ -0,0 +1,309 @@ +// 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 main + +import ( + "bytes" + "errors" + "io" + "os/exec" + "reflect" + "slices" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/yaml" +) + +func TestProviderManifests(t *testing.T) { + for _, tc := range []struct { + name, tool, namespace, prefix string + image string + args []string + }{ + {name: "default", tool: "helm", namespace: "ate-system", args: []string{"template", "substrate", "../../../charts/substrate", "-n", "ate-system"}}, + {name: "custom release", tool: "helm", namespace: "custom", prefix: "test-", + args: []string{"template", "test", "../../../charts/substrate", "-n", "custom", "--set", "credentialProvider.namespacePolicies[0].atespace=team-a", "--set", "credentialProvider.namespacePolicies[0].allowedNamespaces[0]=ns1"}}, + {name: "kustomize", tool: "kubectl", namespace: "ate-system", + args: []string{"kustomize", "../../../manifests/egress-credential-injection"}}, + {name: "CI images", tool: "helm", namespace: "ate-system", + image: "localhost:5001/kagent-dev/substrate/kubernetes-secrets:helm-e2e", + args: []string{"template", "substrate", "../../../charts/substrate", "-n", "ate-system", "--set", "image.registry=localhost:5001", "--set", "image.tag=helm-e2e"}}, + {name: "global images", tool: "helm", namespace: "ate-system", + image: "mirror.example/custom/substrate/kubernetes-secrets:test", + args: []string{"template", "substrate", "../../../charts/substrate", "-n", "ate-system", + "--set", "image.repository=custom/substrate", "--set", "image.tag=test", "--set", "global.imageRegistry=mirror.example", + "--set", "imagePullSecrets[0].name=local", "--set", "global.imagePullSecrets[0].name=global", "--set", "global.imagePullPolicy=Always"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := exec.LookPath(tc.tool); err != nil { + t.Skipf("%s is not installed", tc.tool) + } + data, err := exec.CommandContext(t.Context(), tc.tool, tc.args...).CombinedOutput() + if err != nil { + t.Fatalf("render: %v\n%s", err, data) + } + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + var providerFound, portFound, policyFound, accountFound bool + var roleFound, bindingFound bool + for { + var doc struct { + Kind string + Metadata metav1.ObjectMeta + Spec struct { + Template corev1.PodTemplateSpec + Ports []corev1.ServicePort + } + Data map[string]string + Rules []rbacv1.PolicyRule + RoleRef rbacv1.RoleRef + Subjects []rbacv1.Subject + } + if err := decoder.Decode(&doc); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatal(err) + } + switch doc.Kind { + case "ServiceAccount": + if doc.Metadata.Name == tc.prefix+"k8s-credential-provider" { + accountFound = true + } + case "Deployment": + if doc.Metadata.Name != tc.prefix+"k8s-credential-provider" { + continue + } + pod := doc.Spec.Template.Spec + if pod.ServiceAccountName != tc.prefix+"k8s-credential-provider" { + t.Fatalf("unexpected ServiceAccount %q", pod.ServiceAccountName) + } + for _, container := range pod.Containers { + if container.Name != "k8s-credential-provider" { + continue + } + providerFound = true + if tc.image != "" && container.Image != tc.image { + t.Errorf("image = %q, want %q", container.Image, tc.image) + } + if tc.name == "global images" { + if container.ImagePullPolicy != corev1.PullAlways { + t.Errorf("imagePullPolicy = %q, want Always", container.ImagePullPolicy) + } + for _, name := range []string{"local", "global"} { + if !slices.Contains(pod.ImagePullSecrets, corev1.LocalObjectReference{Name: name}) { + t.Errorf("missing imagePullSecret %q", name) + } + } + } + args := strings.Join(container.Args, " ") + for _, required := range []string{ + "--listen-address=:50051", "--metrics-address=:9090", + "--injector-spiffe-id=spiffe://cluster.local/ns/" + tc.namespace + "/sa/" + tc.prefix + "atenet-egress", + "--server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem", + "--client-ca-file=/run/podidentity.podcert.ate.dev/trust-bundle.pem", + } { + if tc.tool == "kubectl" && strings.HasPrefix(required, "--injector-spiffe-id=") { + continue + } + if !strings.Contains(args, required) { + t.Errorf("provider missing %s", required) + } + } + if container.ReadinessProbe == nil || container.ReadinessProbe.HTTPGet.Port.StrVal != "metrics" { + t.Fatal("missing dedicated readiness probe") + } + } + case "Service": + if doc.Metadata.Name != tc.prefix+"k8s-credential-provider" { + continue + } + for _, port := range doc.Spec.Ports { + if port.Port == 50051 && port.TargetPort.StrVal == "grpc" { + portFound = true + } + } + case "ConfigMap": + if !strings.HasPrefix(doc.Metadata.Name, tc.prefix+"k8s-credential-provider-namespace-policy") { + continue + } + policyFound = true + var policy namespacePolicyFile + if err := yaml.UnmarshalStrict([]byte(doc.Data["namespace-policy.yaml"]), &policy); err != nil { + t.Fatal(err) + } + auth, err := newNamespaceAuthorizer(policy) + if err != nil { + t.Fatal(err) + } + if auth.Allowed("team-a", "ns1") != (tc.name == "custom release") { + t.Fatal("unexpected namespace policy") + } + case "ClusterRole": + if doc.Metadata.Name != tc.prefix+"k8s-credential-provider-secret-reader" { + continue + } + roleFound = true + want := []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}} + if !reflect.DeepEqual(doc.Rules, want) { + t.Fatalf("provider rules = %#v, want get-only Secret access", doc.Rules) + } + case "ClusterRoleBinding": + if doc.Metadata.Name != tc.prefix+"k8s-credential-provider-secret-reader" { + continue + } + bindingFound = true + wantRef := rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: tc.prefix + "k8s-credential-provider-secret-reader"} + wantSubjects := []rbacv1.Subject{{Kind: "ServiceAccount", Name: tc.prefix + "k8s-credential-provider", Namespace: tc.namespace}} + if doc.RoleRef != wantRef || !reflect.DeepEqual(doc.Subjects, wantSubjects) { + t.Fatalf("unexpected provider binding: roleRef=%+v subjects=%+v", doc.RoleRef, doc.Subjects) + } + } + } + if !providerFound || !portFound || !policyFound || !accountFound { + t.Fatalf("provider=%v port=%v policy=%v account=%v", providerFound, portFound, policyFound, accountFound) + } + if !roleFound || !bindingFound { + t.Fatalf("role=%v binding=%v", roleFound, bindingFound) + } + }) + } +} + +func TestAgentgatewayCredentialConfiguration(t *testing.T) { + for _, tc := range []struct { + name, tool, host, roots string + args []string + }{ + {name: "default", tool: "helm", host: "k8s-credential-provider.ate-system.svc:50051", roots: "/run/servicedns.podcert.ate.dev/trust-bundle.pem", + args: []string{"template", "substrate", "../../../charts/substrate", "-n", "ate-system"}}, + {name: "custom release", tool: "helm", host: "test-k8s-credential-provider.custom.svc:50051", roots: "/run/servicedns.podcert.ate.dev/trust-bundle.pem", + args: []string{"template", "test", "../../../charts/substrate", "-n", "custom"}}, + {name: "kustomize", tool: "kubectl", host: "k8s-credential-provider.ate-system.svc:50051", roots: "/run/servicedns-ca/trust-bundle.pem", + args: []string{"kustomize", "--load-restrictor=LoadRestrictionsNone", "../../../manifests/ate-install/agentgateway-egress-mitm"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := exec.LookPath(tc.tool); err != nil { + t.Skipf("%s is not installed", tc.tool) + } + data, err := exec.CommandContext(t.Context(), tc.tool, tc.args...).CombinedOutput() + if err != nil { + t.Fatalf("render: %v\n%s", err, data) + } + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + providers := map[string]int{} + mitmMounts, mitmVolumes, passthroughListeners := 0, 0, 0 + for { + var doc struct { + Kind string + Data map[string]string + Spec struct{ Template corev1.PodTemplateSpec } + } + if err := decoder.Decode(&doc); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatal(err) + } + if doc.Kind == "Deployment" { + for _, volume := range doc.Spec.Template.Spec.Volumes { + if volume.Secret != nil && volume.Secret.SecretName == "egress-mitm-ca-pool" { + mitmVolumes++ + } + } + for _, container := range doc.Spec.Template.Spec.Containers { + if container.Name != "agentgateway" { + continue + } + for _, mount := range container.VolumeMounts { + if mount.MountPath == "/run/egress-mitm" { + mitmMounts++ + } + } + } + } + if doc.Kind != "ConfigMap" { + continue + } + var config struct { + Binds []struct { + Listeners []struct { + Protocol string + TLS struct{ Mode, Cert, Key string } + Routes []struct { + Backends []struct { + Dynamic map[string]any + Policies struct{ BackendTLS map[string]any } + } + Policies struct { + SubstrateEgress struct { + CredentialProviders []struct { + URIAuthority string `json:"uriAuthority"` + Target struct { + Host string + Policies struct { + BackendTLS struct{ Cert, Key, Root string } + } + } + } + } + } + } + } + } + } + if err := yaml.Unmarshal([]byte(doc.Data["config.yaml"]), &config); err != nil { + t.Fatal(err) + } + for _, bind := range config.Binds { + for _, listener := range bind.Listeners { + if listener.Protocol == "TLS" { + passthroughListeners++ + } + for _, route := range listener.Routes { + for _, provider := range route.Policies.SubstrateEgress.CredentialProviders { + providers[listener.Protocol]++ + if listener.Protocol == "HTTPS" { + if listener.TLS.Mode != "dynamicCa" || listener.TLS.Cert != "/run/egress-mitm/tls.crt" || listener.TLS.Key != "/run/egress-mitm/tls.key" { + t.Fatal("incorrect MITM configuration") + } + if len(route.Backends) != 1 || route.Backends[0].Dynamic == nil || len(route.Backends[0].Dynamic) != 0 || route.Backends[0].Policies.BackendTLS == nil || len(route.Backends[0].Policies.BackendTLS) != 0 { + t.Fatal("HTTPS must use a dynamic destination with default public TLS trust") + } + } else if listener.Protocol != "HTTP" { + t.Fatalf("credentials enabled on unexpected protocol %q", listener.Protocol) + } + if provider.URIAuthority != "kubernetes.io" || provider.Target.Host != tc.host { + t.Fatalf("incorrect provider: %+v", provider) + } + tls := provider.Target.Policies.BackendTLS + if tls.Root != tc.roots || tls.Cert != "/run/podidentity.podcert.ate.dev/credential-bundle.pem" || tls.Key != tls.Cert { + t.Fatalf("incorrect provider mTLS: %+v", tls) + } + } + } + } + } + } + if providers["HTTP"] != 1 || providers["HTTPS"] != 1 { + t.Fatalf("providers=%v, want one per HTTP/HTTPS route", providers) + } + if mitmMounts != 1 || mitmVolumes != 1 || passthroughListeners != 0 { + t.Fatalf("MITM mounts=%d volumes=%d passthrough listeners=%d", mitmMounts, mitmVolumes, passthroughListeners) + } + }) + } +} diff --git a/cmd/credential-provider/kubernetes-secrets/nsauthz.go b/cmd/credential-provider/kubernetes-secrets/nsauthz.go new file mode 100644 index 0000000000..c648220898 --- /dev/null +++ b/cmd/credential-provider/kubernetes-secrets/nsauthz.go @@ -0,0 +1,96 @@ +// 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 main + +import ( + "fmt" + "os" + + "github.com/agent-substrate/substrate/internal/resources" + "k8s.io/apimachinery/pkg/util/validation" + + "sigs.k8s.io/yaml" +) + +// namespacePolicyFile is the YAML the authorizer loads: a list of grants, each +// mapping one atespace to the namespaces whose Secrets it may resolve. +type namespacePolicyFile struct { + Policies []atespaceNamespacePolicy `json:"policies"` +} + +type atespaceNamespacePolicy struct { + Atespace string `json:"atespace"` + AllowedNamespaces []string `json:"allowedNamespaces"` +} + +// NamespaceAuthorizer decides whether an atespace may resolve secrets in a given +// Kubernetes namespace. It is default-deny: an atespace absent from the mapping +// can resolve nothing. +type NamespaceAuthorizer struct { + // allowed maps atespace -> set of permitted namespaces. + allowed map[string]map[string]struct{} +} + +// LoadNamespaceAuthorizer reads the YAML policy file at path and builds an +// authorizer, so a malformed file fails startup rather than the first request. +func LoadNamespaceAuthorizer(path string) (*NamespaceAuthorizer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading namespace policy file %q: %w", path, err) + } + var file namespacePolicyFile + if err := yaml.UnmarshalStrict(data, &file); err != nil { + return nil, fmt.Errorf("parsing namespace policy file %q: %w", path, err) + } + return newNamespaceAuthorizer(file) +} + +// newNamespaceAuthorizer builds an authorizer over a parsed policy file, +// validating that each grant names an atespace. +func newNamespaceAuthorizer(file namespacePolicyFile) (*NamespaceAuthorizer, error) { + allowed := make(map[string]map[string]struct{}) + for i, p := range file.Policies { + if !resources.IsValidResourceName(p.Atespace) { + return nil, fmt.Errorf("namespace policy %d: valid atespace is required", i) + } + set := allowed[p.Atespace] + if set == nil { + set = make(map[string]struct{}) + allowed[p.Atespace] = set + } + for _, ns := range p.AllowedNamespaces { + if len(validation.IsDNS1123Label(ns)) != 0 { + return nil, fmt.Errorf("namespace policy %d: invalid namespace %q", i, ns) + } + 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 { + if a == nil { + return false + } + set, ok := a.allowed[atespace] + if !ok { + return false + } + _, ok = set[namespace] + return ok +} diff --git a/docs/kubernetes-credential-provider.md b/docs/kubernetes-credential-provider.md new file mode 100644 index 0000000000..cfc1879480 --- /dev/null +++ b/docs/kubernetes-credential-provider.md @@ -0,0 +1,103 @@ +# Kubernetes credential provider + +The `k8s-credential-provider` Deployment follows the provider from +[upstream](https://github.com/agent-substrate/substrate/pull/1335). It serves +`CredentialProvider.FetchSecret` at `k8s-credential-provider.ate-system.svc:50051` +with its own ServiceAccount and projected serving certificate. AGW calls it +directly over mTLS to inject credentials into HTTP and intercepted HTTPS requests. + +`ate-secret://kubernetes.io/team-a-secrets/example-api/token` resolves the `token` +entry in that Kubernetes Secret. Omitting the key requires exactly one data entry. +The provider reads Kubernetes on every fetch and never persists or logs values. +AGW caches successful credentials per actor and URI for five minutes, so rotation +can take that long to reach injected requests. + +Each request requires a trusted injector certificate with the configured SPIFFE +identity, an explicit atespace-to-namespace grant for the attested actor, and +Kubernetes `get` permission for the provider's ServiceAccount. Both installers +include the upstream get-only Secret ClusterRole and bind it to that ServiceAccount. +The provider can read Secrets across namespaces; its namespace policy controls +which namespaces each actor may use. Empty policies deny all requests. + +## Configure the provider + +Keep the pinned `images.agentgateway` image. It includes the +[protocol update](https://github.com/agentgateway/agentgateway/pull/3524) from +[this build](https://github.com/agentgateway/agentgateway/actions/runs/35238449333) +and implements the current [FetchSecret contract](../pkg/proto/credproviderpb/credprovider.proto). + +Create the MITM CA Secret using the existing installation tooling: + +```sh +hack/install-ate-kind.sh --create-egress-mitm-ca-pool-secret +``` + +The gateway needs `egress-mitm-ca-pool` with `tls.crt` and `tls.key` in its namespace. +Actors making HTTPS requests must trust this CA; see the +[MITM trust bundle guide](egress-trust-bundle.md). + +For Helm, add these values to your release configuration: + +```yaml +credentialProvider: + namespacePolicies: + - atespace: team-a + allowedNamespaces: [team-a-secrets] +``` + +The Helm chart always deploys the provider and configures AGW's HTTP route and +HTTPS interception route. Namespace grants default to an empty list. +Policy changes roll the provider's Pods. Resource names and the injector identity +follow the release: release `demo` in namespace `platform` uses ServiceAccount +`demo-k8s-credential-provider`, endpoint +`demo-k8s-credential-provider.platform.svc:50051`, and injector identity +`spiffe://cluster.local/ns/platform/sa/demo-atenet-egress`. + +HTTPS uses a dynamic backend: AGW selects the destination from the request and +validates its certificate using the system CA roots (`backendTLS: {}`). Public +APIs such as OpenAI and Anthropic need no per-backend certificates. The single +MITM CA lets AGW generate actor-facing certificates as needed. HTTP also travels +through the authenticated CONNECT tunnel, then leaves AGW over plaintext HTTP. + +For the manifest installer, set your grants in +`manifests/egress-credential-injection/namespace-policy.yaml`, then deploy: + +```sh +kubectl kustomize manifests/egress-credential-injection | ko apply -f - +hack/install-ate.sh --deploy-atenet \ + --atenet-dataplane=agentgateway --experimental-use-sdsmint +``` + +The policy file uses `policies:` with the same list of grants as the Helm values. +Its generated ConfigMap name changes with the policy, rolling the provider on +reapplication. Direct ConfigMap edits require a rollout restart: policy and client +CA files are loaded at startup. Serving certificates rotate through the existing +certificate loader. + +## Configure injection + +Create the Secret and set an actor's egress policy header injection to use credential URI +`ate-secret://kubernetes.io/team-a-secrets/example-api/token`, header +`authorization`, and prefix `Bearer `. Namespace grants alone do not create an +egress policy. No ext_proc injector is needed. + +## Tests + +The Helm PR workflow installs the provider and MITM gateway from the start and +runs `internal/e2e/suites/credentials` alongside the standard suites with real actors, +Secrets, chart-managed RBAC, AGW, and the deployed provider. It checks +the exact injected token, an unauthenticated-origin control, namespace-policy +denial and cache isolation between atespaces. The local origin serves HTTP; +the suite uses the installed gateway configuration without modifying ConfigMaps. + +Include `-f internal/e2e/suites/credentials/values.yaml` in the initial Helm +installation to grant the test atespace access. After deploying the standard +MITM egress fixtures, run the suites together: + +```sh +E2E_ATENET_DATAPLANE=agentgateway E2E_CREDENTIAL_PROVIDER=1 E2E_EGRESS_MITM=1 \ + hack/run-e2e-kind.sh -v -args --no-color +``` + +The credential suite tests HTTP injection. The existing MITM suite checks HTTPS +interception and actor trust against a public HTTPS origin using the same install. diff --git a/go.mod b/go.mod index 4edf49121d..2e692a6a65 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spiffe/go-spiffe/v2 v2.7.0 + github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netns v0.0.5 @@ -194,7 +195,6 @@ require ( github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/stretchr/testify v1.12.1 // indirect github.com/testcontainers/testcontainers-go v0.44.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect diff --git a/hack/render-manifests.sh b/hack/render-manifests.sh index 2187044970..e75108a1cb 100755 --- a/hack/render-manifests.sh +++ b/hack/render-manifests.sh @@ -38,6 +38,8 @@ PRESERVED_FILES=( atenet-egress-with-sdsmint.yaml atenet-router.yaml atenet-router-monitoring.yaml + # The provider's upstream manifest lives in manifests/egress-credential-injection. + k8s-credential-provider.yaml pod-certificate-controller.yaml postgres.yaml sandboxconfig-gvisor.yaml diff --git a/internal/e2e/fixture.go b/internal/e2e/fixture.go index ee3d1e6829..9f67b18986 100644 --- a/internal/e2e/fixture.go +++ b/internal/e2e/fixture.go @@ -147,6 +147,16 @@ func DeploySubstrateFixture(t *testing.T, ctx context.Context, clients *Clients, t.Fatalf("fixture %s declares templates in different atespaces (%q and %q)", manifests.Template, atespace, got) } } + t.Cleanup(func() { + // Remove workers before the namespace so its controller does not wait + // on their one-hour termination grace estimate after the Pods exit. + delArgs := []string{"delete", "workerpools", "--all", "--namespace=" + atespace, + "--ignore-not-found", "--cascade=foreground", "--timeout=2m"} + if KubeContext != "" { + delArgs = append([]string{"--context=" + KubeContext}, delArgs...) + } + RunCmd(t, "kubectl", delArgs...) + }) if _, err := clients.SubstrateAPI.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: atespace}}}); err != nil && status.Code(err) != codes.AlreadyExists { t.Fatalf("failed to create atespace %q: %v", atespace, err) diff --git a/internal/e2e/fixtures/testserver/http.go b/internal/e2e/fixtures/testserver/http.go index 0ae303baa8..5eaf72806d 100644 --- a/internal/e2e/fixtures/testserver/http.go +++ b/internal/e2e/fixtures/testserver/http.go @@ -17,19 +17,17 @@ package main import ( "log" "net/http" + "os" "time" "github.com/spf13/cobra" ) // newHTTPCmd is a plain HTTP/1.1 origin an Actor's egress lands on. It exists so -// a test can assert the destination port is recovered from SO_ORIGINAL_DST -// rather than defaulted from the URL scheme: the actor fetches its /healthz on a -// non-standard port, and the gateway's access log is expected to carry that -// port. There is nothing to serve beyond readiness, so /healthz is all it -// answers. +// a test can assert the destination port is recovered from SO_ORIGINAL_DST. +// It can also verify an injected Authorization header against a mounted token. func newHTTPCmd() *cobra.Command { - var listenAddress string + var listenAddress, authorizationFile string cmd := &cobra.Command{ Use: "http", Short: "Serve a plain HTTP/1.1 origin answering /healthz.", @@ -39,6 +37,9 @@ func newHTTPCmd() *cobra.Command { mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + if authorizationFile != "" { + mux.HandleFunc("/credential", credentialHandler(authorizationFile)) + } server := &http.Server{ Addr: listenAddress, @@ -51,5 +52,21 @@ func newHTTPCmd() *cobra.Command { }, } cmd.Flags().StringVar(&listenAddress, "listen", ":8080", "Address the HTTP origin listens on.") + cmd.Flags().StringVar(&authorizationFile, "authorization-file", "", "Enable /credential, requiring a Bearer token matching this file.") return cmd } + +func credentialHandler(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token, err := os.ReadFile(path) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if len(token) == 0 || r.Header.Get("Authorization") != "Bearer "+string(token) { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/e2e/fixtures/testserver/http_test.go b/internal/e2e/fixtures/testserver/http_test.go new file mode 100644 index 0000000000..b42c292677 --- /dev/null +++ b/internal/e2e/fixtures/testserver/http_test.go @@ -0,0 +1,51 @@ +// 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 main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestCredentialHandler(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("expected-token"), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + header string + status int + }{ + {"", http.StatusUnauthorized}, + {"Bearer wrong-token", http.StatusUnauthorized}, + {"Bearer expected-token", http.StatusNoContent}, + } { + req := httptest.NewRequest(http.MethodGet, "/credential", nil) + req.Header.Set("Authorization", tc.header) + resp := httptest.NewRecorder() + credentialHandler(path)(resp, req) + if resp.Code != tc.status || resp.Body.Len() != 0 { + t.Errorf("header %q: status=%d body=%q, want status=%d and no body", tc.header, resp.Code, resp.Body.String(), tc.status) + } + } + resp := httptest.NewRecorder() + credentialHandler(path+"-missing")(resp, httptest.NewRequest(http.MethodGet, "/credential", nil)) + if resp.Code != http.StatusInternalServerError { + t.Fatalf("unreadable credential file: status=%d, want 500", resp.Code) + } +} diff --git a/internal/e2e/suites/credentials/credentials_test.go b/internal/e2e/suites/credentials/credentials_test.go new file mode 100644 index 0000000000..5f5d1d2bb7 --- /dev/null +++ b/internal/e2e/suites/credentials/credentials_test.go @@ -0,0 +1,147 @@ +// 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 credentials + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/agent-substrate/substrate/internal/e2e" + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +// TestKubernetesCredentialInjection uses the Helm values in values.yaml, real +// actors, and a local HTTP origin with the installed gateway configuration and RBAC. +func TestKubernetesCredentialInjection(t *testing.T) { + if os.Getenv("E2E_CREDENTIAL_PROVIDER") == "" { + t.Skip("requires credential E2E namespace grants and E2E_CREDENTIAL_PROVIDER=1") + } + env, err := e2e.CheckEnv("BUCKET_NAME", "KO_DOCKER_REPO") + require.NoError(t, err) + ctx := t.Context() + clients := e2e.GetClients() + namespace, template := e2e.DeployProbe(t, env["BUCKET_NAME"], "credentials") + deniedAtespace, deniedTemplate := e2e.DeployProbe(t, env["BUCKET_NAME"], "credentials-denied") + otherNamespace := e2e.CreateNamespace(t).Name + + for _, secret := range []struct{ namespace, name string }{ + {namespace, "allowed"}, {otherNamespace, "allowed"}, + } { + _, err := clients.K8s.CoreV1().Secrets(secret.namespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secret.name}, + Data: map[string][]byte{"token": []byte("e2e-credential-token")}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + } + + e2e.DeployServerPod(t, ctx, e2e.ServerPod{ + Name: "credential-origin", Namespace: namespace, + ImportPath: "github.com/agent-substrate/substrate/internal/e2e/fixtures/testserver", + Args: []string{"http", "--authorization-file=/run/token/token"}, + Port: 80, TargetPort: 8080, + Volumes: []corev1.Volume{ + {Name: "token", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: "allowed"}}}, + }, + VolumeMounts: []corev1.VolumeMount{{Name: "token", MountPath: "/run/token", ReadOnly: true}}, + }) + host := "credential-origin." + namespace + ".svc" + router, err := e2e.NewRouterClient(ctx) + require.NoError(t, err) + t.Cleanup(router.Close) + // Keep the successful actor alive through the cache-isolation check. + suite := t + for _, tc := range []struct { + name, secretNamespace, secret, want string + }{ + {"without-injection", "", "", "401"}, + {"allowed", namespace, "allowed", "204"}, + {"atespace-denied", namespace, "allowed", "403"}, + {"namespace-denied", otherNamespace, "allowed", "403"}, + } { + t.Run(tc.name, func(t *testing.T) { + atespace, actorTemplate := namespace, template + actorName := tc.name + if tc.name == "atespace-denied" { + // Use the already-fetched URI from an ungranted atespace so an + // incorrectly shared gateway cache cannot bypass authorization. + atespace, actorTemplate = deniedAtespace, deniedTemplate + actorName = "allowed" + } + actor := &ateapipb.ObjectRef{Atespace: atespace, Name: actorName} + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: actor}) + _, _ = clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: actor}) + _, err := clients.SubstrateAPI.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: actorName}, + ActorTemplate: &ateapipb.ObjectRef{Atespace: atespace, Name: actorTemplate.GetMetadata().GetName()}, + }}) + require.NoError(t, err) + cleanupTest := t + if tc.name == "allowed" { + cleanupTest = suite + } + cleanupTest.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + _, _ = clients.SubstrateAPI.SuspendActor(cleanupCtx, &ateapipb.SuspendActorRequest{Actor: actor}) + _, err := clients.SubstrateAPI.DeleteActor(cleanupCtx, &ateapipb.DeleteActorRequest{Actor: actor}) + if err != nil { + cleanupTest.Errorf("delete actor %s/%s: %v", atespace, actorName, err) + } + }) + rule := e2e.EgressAllowHostnames(host) + if tc.secret != "" { + rule.Hostnames.Effects = &ateapipb.EgressRuleEffects{InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{ + Header: "authorization", Prefix: "Bearer ", + CredentialUri: fmt.Sprintf("ate-secret://kubernetes.io/%s/%s/token", tc.secretNamespace, tc.secret), + }}} + } + e2e.EnsureEgressPolicy(t, ctx, clients, actor, rule) + _, err = clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: actor}) + require.NoError(t, err) + path := "/fetch?roots=system&url=" + url.QueryEscape("http://"+host+"/credential") + // Route discovery is asynchronous. Retries require the precise status; + // transport errors never pass. + deadline := time.Now().Add(90 * time.Second) + for { + resp, err := router.Get(ctx, resources.ActorRef{Atespace: atespace, Name: actorName}, path) + var body []byte + if err == nil { + body, err = io.ReadAll(resp.Body) + resp.Body.Close() + var result struct{ Status, Error string } + if err == nil && resp.StatusCode == http.StatusOK && json.Unmarshal(body, &result) == nil && result.Error == "" && result.Status == tc.want { + break + } + } + if time.Now().After(deadline) { + t.Fatalf("want origin status %s; last response: %s; error: %v", tc.want, body, err) + } + time.Sleep(2 * time.Second) + } + }) + } +} diff --git a/internal/e2e/suites/credentials/testmain_test.go b/internal/e2e/suites/credentials/testmain_test.go new file mode 100644 index 0000000000..6b8549a17a --- /dev/null +++ b/internal/e2e/suites/credentials/testmain_test.go @@ -0,0 +1,24 @@ +// 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 credentials + +import ( + "os" + "testing" + + "github.com/agent-substrate/substrate/internal/e2e" +) + +func TestMain(m *testing.M) { os.Exit(e2e.RunTestMain(m)) } diff --git a/internal/e2e/suites/credentials/values.yaml b/internal/e2e/suites/credentials/values.yaml new file mode 100644 index 0000000000..27ff95125a --- /dev/null +++ b/internal/e2e/suites/credentials/values.yaml @@ -0,0 +1,18 @@ +# 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. + +credentialProvider: + namespacePolicies: + - atespace: ate-e2e-probe-credentials + allowedNamespaces: [ate-e2e-probe-credentials] diff --git a/manifests/ate-install/components/agentgateway-egress-mitm/kustomization.yaml b/manifests/ate-install/components/agentgateway-egress-mitm/kustomization.yaml index b90274819d..c40e6de9da 100644 --- a/manifests/ate-install/components/agentgateway-egress-mitm/kustomization.yaml +++ b/manifests/ate-install/components/agentgateway-egress-mitm/kustomization.yaml @@ -72,6 +72,15 @@ patches: cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem key: /run/podidentity.podcert.ate.dev/credential-bundle.pem root: /run/servicedns-ca/trust-bundle.pem + credentialProviders: + - uriAuthority: kubernetes.io + target: + host: k8s-credential-provider.ate-system.svc:50051 + policies: + backendTLS: + cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem + key: /run/podidentity.podcert.ate.dev/credential-bundle.pem + root: /run/servicedns-ca/trust-bundle.pem - protocol: HTTP routes: - backends: @@ -85,6 +94,15 @@ patches: cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem key: /run/podidentity.podcert.ate.dev/credential-bundle.pem root: /run/servicedns-ca/trust-bundle.pem + credentialProviders: + - uriAuthority: kubernetes.io + target: + host: k8s-credential-provider.ate-system.svc:50051 + policies: + backendTLS: + cert: /run/podidentity.podcert.ate.dev/credential-bundle.pem + key: /run/podidentity.podcert.ate.dev/credential-bundle.pem + root: /run/servicedns-ca/trust-bundle.pem - protocol: TCP tcpRoutes: - backends: diff --git a/manifests/ate-install/components/agentgateway/kustomization.yaml b/manifests/ate-install/components/agentgateway/kustomization.yaml index fe062e4447..cdff87553f 100644 --- a/manifests/ate-install/components/agentgateway/kustomization.yaml +++ b/manifests/ate-install/components/agentgateway/kustomization.yaml @@ -42,7 +42,7 @@ patches: path: /spec/template/spec/containers/0 value: name: agentgateway - image: ghcr.io/agentgateway/agentgateway:v0.0.0-alpha.9f9744cf + image: ghcr.io/agentgateway/agentgateway:v0.0.0-alpha.8dba3989@sha256:fdde26d4b0ea11d3e740dc19905dfe9b26e88f40fa8b9985f94ed1d8420e389e args: - -f - /etc/agentgateway/config.yaml @@ -118,7 +118,7 @@ patches: path: /spec/template/spec/containers/0 value: name: agentgateway - image: ghcr.io/agentgateway/agentgateway:v0.0.0-alpha.9f9744cf + image: ghcr.io/agentgateway/agentgateway:v0.0.0-alpha.8dba3989@sha256:fdde26d4b0ea11d3e740dc19905dfe9b26e88f40fa8b9985f94ed1d8420e389e args: - -f - /etc/agentgateway/config.yaml diff --git a/manifests/egress-credential-injection/k8s-credential-provider.yaml b/manifests/egress-credential-injection/k8s-credential-provider.yaml new file mode 100644 index 0000000000..50fa38eaba --- /dev/null +++ b/manifests/egress-credential-injection/k8s-credential-provider.yaml @@ -0,0 +1,156 @@ +# 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 ate-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: k8s-credential-provider + namespace: ate-system +--- +# The provider checks the actor's atespace-to-namespace grant before reading. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: k8s-credential-provider-secret-reader +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: k8s-credential-provider-secret-reader +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: k8s-credential-provider-secret-reader +subjects: +- kind: ServiceAccount + name: k8s-credential-provider + namespace: ate-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: k8s-credential-provider + namespace: ate-system + labels: + app: k8s-credential-provider +spec: + replicas: 1 + selector: + matchLabels: + app: k8s-credential-provider + template: + metadata: + labels: + app: k8s-credential-provider + spec: + serviceAccountName: k8s-credential-provider + securityContext: + runAsUser: 65532 + runAsGroup: 65532 + runAsNonRoot: true + containers: + - name: k8s-credential-provider + image: ko://github.com/agent-substrate/substrate/cmd/credential-provider/kubernetes-secrets + args: + - "--listen-address=:50051" + - "--metrics-address=:9090" + # Serve with the pod's servicedns identity (SAN k8s-credential-provider.ate-system.svc) + # and require the injector to present a podidentity client cert whose chain + # verifies against the trust bundle. The provider additionally pins the + # caller's SAN to the egress gateway's identity + # (spiffe://cluster.local/ns/ate-system/sa/atenet-egress), so no other + # CA-trusted workload can fetch secrets. + - "--server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem" + - "--client-ca-file=/run/podidentity.podcert.ate.dev/trust-bundle.pem" + # Enforce the atespace→namespace authorization policy (default-deny). + - "--namespace-policy-file=/etc/k8s-credential-provider/namespace-policy.yaml" + - "--log-level=info" + ports: + - name: grpc + containerPort: 50051 + - name: metrics + containerPort: 9090 + readinessProbe: + httpGet: + path: /readyz + port: metrics + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: namespace-policy + mountPath: /etc/k8s-credential-provider + 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: k8s-credential-provider-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: k8s-credential-provider + namespace: ate-system +spec: + type: ClusterIP + selector: + app: k8s-credential-provider + ports: + - name: grpc + port: 50051 + targetPort: grpc + protocol: TCP diff --git a/manifests/egress-credential-injection/kustomization.yaml b/manifests/egress-credential-injection/kustomization.yaml new file mode 100644 index 0000000000..19bf054fef --- /dev/null +++ b/manifests/egress-credential-injection/kustomization.yaml @@ -0,0 +1,25 @@ +# 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. + +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- k8s-credential-provider.yaml + +configMapGenerator: +- name: k8s-credential-provider-namespace-policy + namespace: ate-system + files: + - namespace-policy.yaml diff --git a/manifests/egress-credential-injection/namespace-policy.yaml b/manifests/egress-credential-injection/namespace-policy.yaml new file mode 100644 index 0000000000..78747a0d26 --- /dev/null +++ b/manifests/egress-credential-injection/namespace-policy.yaml @@ -0,0 +1,16 @@ +# 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. + +# Default-deny atespace-to-namespace grants. Secret RBAC is configured separately. +policies: []