diff --git a/api/v1beta1/backendsecurity_policy.go b/api/v1beta1/backendsecurity_policy.go index 900bdf3918..014dab888c 100644 --- a/api/v1beta1/backendsecurity_policy.go +++ b/api/v1beta1/backendsecurity_policy.go @@ -16,12 +16,13 @@ import ( type BackendSecurityPolicyType string const ( - BackendSecurityPolicyTypeAPIKey BackendSecurityPolicyType = "APIKey" - BackendSecurityPolicyTypeAWSCredentials BackendSecurityPolicyType = "AWSCredentials" - BackendSecurityPolicyTypeAzureAPIKey BackendSecurityPolicyType = "AzureAPIKey" - BackendSecurityPolicyTypeAnthropicAPIKey BackendSecurityPolicyType = "AnthropicAPIKey" // #nosec G101 - BackendSecurityPolicyTypeAzureCredentials BackendSecurityPolicyType = "AzureCredentials" - BackendSecurityPolicyTypeGCPCredentials BackendSecurityPolicyType = "GCPCredentials" + BackendSecurityPolicyTypeAPIKey BackendSecurityPolicyType = "APIKey" + BackendSecurityPolicyTypeAWSCredentials BackendSecurityPolicyType = "AWSCredentials" + BackendSecurityPolicyTypeAzureAPIKey BackendSecurityPolicyType = "AzureAPIKey" + BackendSecurityPolicyTypeAnthropicAPIKey BackendSecurityPolicyType = "AnthropicAPIKey" // #nosec G101 + BackendSecurityPolicyTypeAzureCredentials BackendSecurityPolicyType = "AzureCredentials" + BackendSecurityPolicyTypeGCPCredentials BackendSecurityPolicyType = "GCPCredentials" + BackendSecurityPolicyTypeOpenAICredentials BackendSecurityPolicyType = "OpenAICredentials" // #nosec G101 ) // BackendSecurityPolicy specifies configuration for authentication and authorization rules on the traffic @@ -47,12 +48,13 @@ type BackendSecurityPolicy struct { // // Only one type of BackendSecurityPolicy can be defined. // +kubebuilder:validation:MaxProperties=4 -// +kubebuilder:validation:XValidation:rule="self.type == 'APIKey' ? (has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true",message="When type is APIKey, only apiKey field should be set" -// +kubebuilder:validation:XValidation:rule="self.type == 'AWSCredentials' ? (has(self.awsCredentials) && !has(self.apiKey) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true",message="When type is AWSCredentials, only awsCredentials field should be set" -// +kubebuilder:validation:XValidation:rule="self.type == 'AzureAPIKey' ? (has(self.azureAPIKey) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true",message="When type is AzureAPIKey, only azureAPIKey field should be set" -// +kubebuilder:validation:XValidation:rule="self.type == 'AzureCredentials' ? (has(self.azureCredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true",message="When type is AzureCredentials, only azureCredentials field should be set" -// +kubebuilder:validation:XValidation:rule="self.type == 'GCPCredentials' ? (has(self.gcpCredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.anthropicAPIKey)) : true",message="When type is GCPCredentials, only gcpCredentials field should be set" -// +kubebuilder:validation:XValidation:rule="self.type == 'AnthropicAPIKey' ? (has(self.anthropicAPIKey) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials)) : true",message="When type is AnthropicAPIKey, only anthropicAPIKey field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'APIKey' ? (has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true",message="When type is APIKey, only apiKey field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'AWSCredentials' ? (has(self.awsCredentials) && !has(self.apiKey) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true",message="When type is AWSCredentials, only awsCredentials field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'AzureAPIKey' ? (has(self.azureAPIKey) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true",message="When type is AzureAPIKey, only azureAPIKey field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'AzureCredentials' ? (has(self.azureCredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true",message="When type is AzureCredentials, only azureCredentials field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'GCPCredentials' ? (has(self.gcpCredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true",message="When type is GCPCredentials, only gcpCredentials field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'AnthropicAPIKey' ? (has(self.anthropicAPIKey) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.openAICredentials)) : true",message="When type is AnthropicAPIKey, only anthropicAPIKey field should be set" +// +kubebuilder:validation:XValidation:rule="self.type == 'OpenAICredentials' ? (has(self.openAICredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true",message="When type is OpenAICredentials, only openAICredentials field should be set" type BackendSecurityPolicySpec struct { // TargetRefs are the names of the AIServiceBackend or InferencePool resources this BackendSecurityPolicy is being attached to. // Attaching multiple BackendSecurityPolicies to the same resource is invalid and will result in an error @@ -65,7 +67,7 @@ type BackendSecurityPolicySpec struct { // Type specifies the type of the backend security policy. // - // +kubebuilder:validation:Enum=APIKey;AWSCredentials;AzureAPIKey;AzureCredentials;GCPCredentials;AnthropicAPIKey + // +kubebuilder:validation:Enum=APIKey;AWSCredentials;AzureAPIKey;AzureCredentials;GCPCredentials;AnthropicAPIKey;OpenAICredentials Type BackendSecurityPolicyType `json:"type"` // APIKey is a mechanism to access a backend(s). The API key will be injected into the Authorization header. @@ -99,6 +101,13 @@ type BackendSecurityPolicySpec struct { // +optional AnthropicAPIKey *BackendSecurityPolicyAnthropicAPIKey `json:"anthropicAPIKey,omitempty"` + // OpenAICredentials is a mechanism to access OpenAI backend(s) without a static API key. The controller + // exchanges a subject token for a short-lived access token via OpenAI Workload Identity Federation, which is + // injected into the Authorization header. + // + // +optional + OpenAICredentials *BackendSecurityPolicyOpenAICredentials `json:"openAICredentials,omitempty"` + // CredentialOverride, when set, sources the upstream credential per-request instead of using // the static credential configured above. // @@ -347,6 +356,96 @@ type BackendSecurityPolicyAnthropicAPIKey struct { SecretRef *gwapiv1.SecretObjectReference `json:"secretRef"` } +// BackendSecurityPolicyOpenAICredentials contains the supported authentication mechanisms to access OpenAI. +type BackendSecurityPolicyOpenAICredentials struct { + // Organization is the OpenAI organization ID. When set, it is sent in the "OpenAI-Organization" header. + // + // +optional + // +kubebuilder:validation:MinLength=1 + Organization string `json:"organization,omitempty"` + + // Project is the OpenAI project ID. When set, it is sent in the "OpenAI-Project" header. + // + // +optional + // +kubebuilder:validation:MinLength=1 + Project string `json:"project,omitempty"` + + // WorkloadIdentityFederationConfig is the configuration for OpenAI Workload Identity Federation. + // + // +kubebuilder:validation:Required + WorkloadIdentityFederationConfig OpenAIWorkloadIdentityFederationConfig `json:"workloadIdentityFederationConfig"` +} + +// OpenAIWorkloadIdentityFederationConfig specifies how the controller obtains an OpenAI access token via +// OpenAI Workload Identity Federation. The controller obtains a subject token, exchanges it at +// https://auth.openai.com/oauth/token for a short-lived OpenAI access token, and stores the access token in a secret, rotating it before it expires. +// +// The identity provider and service account mapping must be configured in the OpenAI organization, with the +// issuer and audience of the subject token. +// +// https://developers.openai.com/api/reference/workload-identity-federation +type OpenAIWorkloadIdentityFederationConfig struct { + // IdentityProviderID is the ID of the OpenAI Workload Identity Provider configured for the issuer of the + // subject token, e.g. "idp_abc123". + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + IdentityProviderID string `json:"identityProviderID"` + + // ServiceAccountID is the ID of the OpenAI service account the access token is issued for. It must match a + // service account mapping of the identity provider. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ServiceAccountID string `json:"serviceAccountID"` + + // SubjectToken specifies where the subject token is obtained from. The subject token must be a JWT. + // + // +kubebuilder:validation:Required + SubjectToken OpenAISubjectToken `json:"subjectToken"` +} + +// OpenAISubjectToken specifies the source of the subject token exchanged for an OpenAI access token. +// Exactly one source must be configured. +// +// +kubebuilder:validation:XValidation:rule="[has(self.oidcExchangeToken), has(self.spiffeJWTSVID)].filter(x, x).size() == 1",message="Exactly one of oidcExchangeToken or spiffeJWTSVID must be specified" +type OpenAISubjectToken struct { + // OIDCExchangeToken obtains the subject token from an OIDC provider via the client-credentials flow. + // The OIDC provider must return a JWT access token. + // + // +optional + OIDCExchangeToken *BackendSecurityPolicyOIDC `json:"oidcExchangeToken,omitempty"` + + // SPIFFEJWTSVID obtains the subject token as a JWT-SVID from the SPIFFE Workload API. + // + // The SPIFFE identity is owned by the controller, not by the policy: the JWT-SVID is issued for the + // controller's SPIFFE ID. This differs from OIDCExchangeToken, where the policy supplies its own + // credentials. Every policy using this source authenticates as the controller and can obtain access + // tokens for any OpenAI service account mapped to the controller's SPIFFE ID, so only trusted cluster + // admins should be allowed to create such policies. + // + // +optional + SPIFFEJWTSVID *BackendSecurityPolicySPIFFEJWTSVID `json:"spiffeJWTSVID,omitempty"` +} + +// BackendSecurityPolicySPIFFEJWTSVID specifies how to obtain a JWT-SVID from the SPIFFE Workload API. +type BackendSecurityPolicySPIFFEJWTSVID struct { + // Audience is the audience ("aud" claim) of the requested JWT-SVID. The token exchange endpoint + // validates it, so it must match the audience that endpoint expects, e.g. the audience configured + // for the OpenAI Workload Identity Provider. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Audience string `json:"audience"` + + // SocketPath is the address of the SPIFFE Workload API, e.g. "unix:///run/spire/sockets/agent.sock". + // When unset, the SPIFFE_ENDPOINT_SOCKET environment variable of the controller is used. + // + // +optional + // +kubebuilder:validation:Pattern=`^(unix|tcp)://.+` + SocketPath string `json:"socketPath,omitempty"` +} + // BackendSecurityPolicyCredentialOverride configures per-request credential sourcing. // A trusted upstream filter (ext_authz or a preceding ext_proc) resolves the caller's // credential and delivers it via one of the two sources below. @@ -379,6 +478,7 @@ type CredentialOverrideFromRequestHeaders struct { // AzureAPIKey → x-aigw-azure-api-key // AzureCredentials → x-aigw-azure-access-token // GCPCredentials → x-aigw-gcp-access-token + // OpenAICredentials → x-aigw-openai-access-token // // For AWSCredentials this is a prefix, not a single header name, since SigV4 needs three // inputs. Defaults to "x-aigw-aws-", yielding: diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 702b47c414..ea0e318d00 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -666,6 +666,37 @@ func (in *BackendSecurityPolicyOIDC) DeepCopy() *BackendSecurityPolicyOIDC { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackendSecurityPolicyOpenAICredentials) DeepCopyInto(out *BackendSecurityPolicyOpenAICredentials) { + *out = *in + in.WorkloadIdentityFederationConfig.DeepCopyInto(&out.WorkloadIdentityFederationConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendSecurityPolicyOpenAICredentials. +func (in *BackendSecurityPolicyOpenAICredentials) DeepCopy() *BackendSecurityPolicyOpenAICredentials { + if in == nil { + return nil + } + out := new(BackendSecurityPolicyOpenAICredentials) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackendSecurityPolicySPIFFEJWTSVID) DeepCopyInto(out *BackendSecurityPolicySPIFFEJWTSVID) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendSecurityPolicySPIFFEJWTSVID. +func (in *BackendSecurityPolicySPIFFEJWTSVID) DeepCopy() *BackendSecurityPolicySPIFFEJWTSVID { + if in == nil { + return nil + } + out := new(BackendSecurityPolicySPIFFEJWTSVID) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BackendSecurityPolicySpec) DeepCopyInto(out *BackendSecurityPolicySpec) { *out = *in @@ -704,6 +735,11 @@ func (in *BackendSecurityPolicySpec) DeepCopyInto(out *BackendSecurityPolicySpec *out = new(BackendSecurityPolicyAnthropicAPIKey) (*in).DeepCopyInto(*out) } + if in.OpenAICredentials != nil { + in, out := &in.OpenAICredentials, &out.OpenAICredentials + *out = new(BackendSecurityPolicyOpenAICredentials) + (*in).DeepCopyInto(*out) + } if in.CredentialOverride != nil { in, out := &in.CredentialOverride, &out.CredentialOverride *out = new(BackendSecurityPolicyCredentialOverride) @@ -1756,6 +1792,47 @@ func (in *MCPToolFilter) DeepCopy() *MCPToolFilter { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenAISubjectToken) DeepCopyInto(out *OpenAISubjectToken) { + *out = *in + if in.OIDCExchangeToken != nil { + in, out := &in.OIDCExchangeToken, &out.OIDCExchangeToken + *out = new(BackendSecurityPolicyOIDC) + (*in).DeepCopyInto(*out) + } + if in.SPIFFEJWTSVID != nil { + in, out := &in.SPIFFEJWTSVID, &out.SPIFFEJWTSVID + *out = new(BackendSecurityPolicySPIFFEJWTSVID) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenAISubjectToken. +func (in *OpenAISubjectToken) DeepCopy() *OpenAISubjectToken { + if in == nil { + return nil + } + out := new(OpenAISubjectToken) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenAIWorkloadIdentityFederationConfig) DeepCopyInto(out *OpenAIWorkloadIdentityFederationConfig) { + *out = *in + in.SubjectToken.DeepCopyInto(&out.SubjectToken) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenAIWorkloadIdentityFederationConfig. +func (in *OpenAIWorkloadIdentityFederationConfig) DeepCopy() *OpenAIWorkloadIdentityFederationConfig { + if in == nil { + return nil + } + out := new(OpenAIWorkloadIdentityFederationConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProtectedResourceMetadata) DeepCopyInto(out *ProtectedResourceMetadata) { *out = *in diff --git a/examples/basic/README.md b/examples/basic/README.md index fb0c022a17..43df21a53f 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -5,6 +5,7 @@ traffic for various AI providers. - `basic.yaml` - Basic configuration without any backends - `openai.yaml` - OpenAI integration +- `openai-spiffe.yaml` - OpenAI integration without a static API key, using SPIFFE and OAuth 2.0 Token Exchange - `aws.yaml` - AWS Bedrock with static credentials - `aws-irsa.yaml` - AWS Bedrock with IRSA (IAM Roles for Service Accounts) - `aws-pod-identity.yaml` - AWS Bedrock with EKS Pod Identity diff --git a/examples/basic/openai-spiffe.yaml b/examples/basic/openai-spiffe.yaml new file mode 100644 index 0000000000..ac677e2dad --- /dev/null +++ b/examples/basic/openai-spiffe.yaml @@ -0,0 +1,94 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: AIGatewayRoute +metadata: + name: envoy-ai-gateway-basic-openai-spiffe + namespace: default +spec: + parentRefs: + - name: envoy-ai-gateway-basic + kind: Gateway + group: gateway.networking.k8s.io + rules: + - matches: + - headers: + - type: Exact + name: x-ai-eg-model + value: gpt-4o-mini + backendRefs: + - name: envoy-ai-gateway-basic-openai-spiffe +--- +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: AIServiceBackend +metadata: + name: envoy-ai-gateway-basic-openai-spiffe + namespace: default +spec: + schema: + name: OpenAI + backendRef: + name: envoy-ai-gateway-basic-openai-spiffe + kind: Backend + group: gateway.envoyproxy.io +--- +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: envoy-ai-gateway-basic-openai-spiffe-credentials + namespace: default +spec: + targetRefs: + - group: aigateway.envoyproxy.io + kind: AIServiceBackend + name: envoy-ai-gateway-basic-openai-spiffe + type: OpenAICredentials + # The controller fetches a JWT-SVID for its own SPIFFE ID and exchanges it for a short-lived OpenAI + # access token via OpenAI Workload Identity Federation, rotating it before it expires. The SPIFFE + # Workload API socket must be mounted into the controller, with SPIFFE_ENDPOINT_SOCKET pointing at it + # (or set socketPath below). + # + # The identity provider (trusting your SPIFFE trust domain) and the service account mapping must be + # configured in your OpenAI organization: + # https://developers.openai.com/api/reference/workload-identity-federation + # + # The SPIFFE identity is owned by the controller, not by this policy (unlike oidcExchangeToken, where + # the policy supplies its own credentials). Every policy using spiffeJWTSVID authenticates as the + # controller, so only trusted cluster admins should be allowed to create them. + openAICredentials: + organization: OPENAI_ORGANIZATION_ID # Optional. Replace with your OpenAI organization ID, or remove. + project: OPENAI_PROJECT_ID # Optional. Replace with your OpenAI project ID, or remove. + workloadIdentityFederationConfig: + identityProviderID: OPENAI_IDENTITY_PROVIDER_ID # Replace with your OpenAI Workload Identity Provider ID. + serviceAccountID: OPENAI_SERVICE_ACCOUNT_ID # Replace with your OpenAI service account ID. + subjectToken: + spiffeJWTSVID: + audience: OPENAI_IDENTITY_PROVIDER_AUDIENCE # Replace with the audience configured for the identity provider. +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: Backend +metadata: + name: envoy-ai-gateway-basic-openai-spiffe + namespace: default +spec: + endpoints: + - fqdn: + hostname: api.openai.com + port: 443 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: BackendTLSPolicy +metadata: + name: envoy-ai-gateway-basic-openai-spiffe-tls + namespace: default +spec: + targetRefs: + - group: "gateway.envoyproxy.io" + kind: Backend + name: envoy-ai-gateway-basic-openai-spiffe + validation: + wellKnownCACertificates: "System" + hostname: api.openai.com diff --git a/go.mod b/go.mod index 0c0c48b9f6..c507c7c8af 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.70.1 + github.com/spiffe/go-spiffe/v2 v2.8.1 github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go v0.43.0 github.com/tetratelabs/func-e v1.6.0 @@ -215,7 +216,6 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect github.com/telepresenceio/watchable v0.0.0-20220726211108-9bb86f92afa7 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/internal/backendauth/auth.go b/internal/backendauth/auth.go index 69abf894e4..40d11d0bd6 100644 --- a/internal/backendauth/auth.go +++ b/internal/backendauth/auth.go @@ -45,6 +45,9 @@ func NewHandler(ctx context.Context, config *filterapi.BackendAuth) (filterapi.B case config.GCPAuth != nil: inner, err = newGCPHandler(ctx, config.GCPAuth) applyFn = makeGCPApplyFn(config.GCPAuth.Region, config.GCPAuth.ProjectName) + case config.OpenAIAuth != nil: + inner, err = newOpenAIHandler(config.OpenAIAuth) + applyFn = makeOpenAIApplyFn(config.OpenAIAuth.Organization, config.OpenAIAuth.Project) case config.AnthropicAPIKey != nil: inner, err = newAnthropicAPIKeyHandler(config.AnthropicAPIKey) applyFn = applyAnthropicCredential diff --git a/internal/backendauth/auth_test.go b/internal/backendauth/auth_test.go index 7d445b55b1..1229221281 100644 --- a/internal/backendauth/auth_test.go +++ b/internal/backendauth/auth_test.go @@ -56,6 +56,12 @@ aws_secret_access_key = test AnthropicAPIKey: &filterapi.AnthropicAPIKeyAuth{Key: "TEST"}, }, }, + { + name: "OpenAIAuth", + config: &filterapi.BackendAuth{ + OpenAIAuth: &filterapi.OpenAIAuth{AccessToken: "some-access-token", Organization: "org-123"}, + }, + }, } { t.Run(tt.name, func(t *testing.T) { _, err := NewHandler(t.Context(), tt.config) diff --git a/internal/backendauth/credential_override_test.go b/internal/backendauth/credential_override_test.go index b0b92cd819..c9b3141170 100644 --- a/internal/backendauth/credential_override_test.go +++ b/internal/backendauth/credential_override_test.go @@ -252,6 +252,25 @@ func TestCredentialOverrideHandler_PerAuthType(t *testing.T) { require.Equal(t, "Bearer per-req-token", headers["Authorization"]) }) + t.Run("openai credentials sets Authorization Bearer and organization/project", func(t *testing.T) { + auth := &filterapi.OpenAIAuth{AccessToken: "static", Organization: "org-123", Project: "proj_456"} + inner, err := newOpenAIHandler(auth) + require.NoError(t, err) + + h := &credentialOverrideHandler{ + inner: inner, + config: makeHeaderOverride("x-aigw-openai-access-token", false), + applyFn: makeOpenAIApplyFn(auth.Organization, auth.Project), + } + + headers := map[string]string{"x-aigw-openai-access-token": "per-req-token"} + _, err = h.Do(t.Context(), headers, nil) + require.NoError(t, err) + require.Equal(t, "Bearer per-req-token", headers["Authorization"]) + require.Equal(t, "org-123", headers["OpenAI-Organization"]) + require.Equal(t, "proj_456", headers["OpenAI-Project"]) + }) + t.Run("gcp credentials sets Authorization Bearer and rewrites path", func(t *testing.T) { inner, err := newGCPHandler(t.Context(), &filterapi.GCPAuth{ AccessToken: "static-token", diff --git a/internal/backendauth/openai.go b/internal/backendauth/openai.go new file mode 100644 index 0000000000..658fa67cbf --- /dev/null +++ b/internal/backendauth/openai.go @@ -0,0 +1,61 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package backendauth + +import ( + "context" + "fmt" + "strings" + + "github.com/envoyproxy/ai-gateway/internal/filterapi" + "github.com/envoyproxy/ai-gateway/internal/internalapi" +) + +const ( + // openAIOrganizationHeader is the header that selects the OpenAI organization of a request. + openAIOrganizationHeader = "OpenAI-Organization" + // openAIProjectHeader is the header that selects the OpenAI project of a request. + openAIProjectHeader = "OpenAI-Project" +) + +type openAIHandler struct { + accessToken string + applyFn applyCredentialFn +} + +func newOpenAIHandler(auth *filterapi.OpenAIAuth) (filterapi.BackendAuthHandler, error) { + return &openAIHandler{ + accessToken: strings.TrimSpace(auth.AccessToken), + applyFn: makeOpenAIApplyFn(auth.Organization, auth.Project), + }, nil +} + +// Do implements [Handler.Do]. +// +// Sets the OpenAI access token as an authorization header, along with the organization and project +// headers when configured. +func (o *openAIHandler) Do(_ context.Context, requestHeaders map[string]string, _ []byte) ([]internalapi.Header, error) { + return o.applyFn(requestHeaders, o.accessToken) +} + +// makeOpenAIApplyFn returns an applyCredentialFn that sets Authorization: Bearer and, +// when non-empty, the OpenAI-Organization and OpenAI-Project headers. +func makeOpenAIApplyFn(organization, project string) applyCredentialFn { + return func(requestHeaders map[string]string, credential string) ([]internalapi.Header, error) { + v := fmt.Sprintf("Bearer %s", credential) + requestHeaders["Authorization"] = v + headers := []internalapi.Header{{"Authorization", v}} + if organization != "" { + requestHeaders[openAIOrganizationHeader] = organization + headers = append(headers, internalapi.Header{openAIOrganizationHeader, organization}) + } + if project != "" { + requestHeaders[openAIProjectHeader] = project + headers = append(headers, internalapi.Header{openAIProjectHeader, project}) + } + return headers, nil + } +} diff --git a/internal/backendauth/openai_test.go b/internal/backendauth/openai_test.go new file mode 100644 index 0000000000..518e9f3979 --- /dev/null +++ b/internal/backendauth/openai_test.go @@ -0,0 +1,65 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package backendauth + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/envoyproxy/ai-gateway/internal/filterapi" + "github.com/envoyproxy/ai-gateway/internal/internalapi" +) + +func TestNewOpenAIHandler(t *testing.T) { + handler, err := newOpenAIHandler(&filterapi.OpenAIAuth{AccessToken: " some-access-token \n"}) + require.NoError(t, err) + require.Equal(t, "some-access-token", handler.(*openAIHandler).accessToken) +} + +func TestOpenAIHandler_Do(t *testing.T) { + for _, tc := range []struct { + name string + auth filterapi.OpenAIAuth + expHeaders []internalapi.Header + }{ + { + name: "token only", + auth: filterapi.OpenAIAuth{AccessToken: "some-access-token"}, + expHeaders: []internalapi.Header{{"Authorization", "Bearer some-access-token"}}, + }, + { + name: "organization and project", + auth: filterapi.OpenAIAuth{AccessToken: "some-access-token", Organization: "org-123", Project: "proj_456"}, + expHeaders: []internalapi.Header{ + {"Authorization", "Bearer some-access-token"}, + {"OpenAI-Organization", "org-123"}, + {"OpenAI-Project", "proj_456"}, + }, + }, + { + name: "project only", + auth: filterapi.OpenAIAuth{AccessToken: "some-access-token", Project: "proj_456"}, + expHeaders: []internalapi.Header{ + {"Authorization", "Bearer some-access-token"}, + {"OpenAI-Project", "proj_456"}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + handler, err := newOpenAIHandler(&tc.auth) + require.NoError(t, err) + + requestHeaders := map[string]string{":method": "POST", ":path": "/v1/chat/completions"} + headers, err := handler.Do(t.Context(), requestHeaders, nil) + require.NoError(t, err) + require.Equal(t, tc.expHeaders, headers) + for _, h := range tc.expHeaders { + require.Equal(t, h[1], requestHeaders[h[0]]) + } + }) + } +} diff --git a/internal/controller/backend_security_policy.go b/internal/controller/backend_security_policy.go index f1c50f0c3a..7f5d038b0e 100644 --- a/internal/controller/backend_security_policy.go +++ b/internal/controller/backend_security_policy.go @@ -276,6 +276,16 @@ func (c *BackendSecurityPolicyController) rotateCredential(ctx context.Context, return ctrl.Result{}, nil } + case aigv1b1.BackendSecurityPolicyTypeOpenAICredentials: + var provider tokenprovider.TokenProvider + provider, err = c.newOpenAITokenProvider(ctx, bsp) + if err != nil { + return ctrl.Result{}, err + } + rotator, err = rotators.NewOpenAITokenRotator(c.client, c.kube, c.logger, bsp.Namespace, bsp.Name, c.preRotationWindow, provider) + if err != nil { + return ctrl.Result{}, err + } default: err = fmt.Errorf("backend security type %s does not support OIDC token exchange", bsp.Spec.Type) c.logger.Error(err, "unsupported backend security type", "namespace", bsp.Namespace, "name", bsp.Name) @@ -342,6 +352,32 @@ func (c *BackendSecurityPolicyController) executeRotation(ctx context.Context, r return ctrl.Result{RequeueAfter: requeue}, err } +// newOpenAITokenProvider returns a TokenProvider that exchanges the configured subject token for an +// OpenAI access token via OpenAI Workload Identity Federation. +func (c *BackendSecurityPolicyController) newOpenAITokenProvider(ctx context.Context, bsp *aigv1b1.BackendSecurityPolicy) (tokenprovider.TokenProvider, error) { + wif := &bsp.Spec.OpenAICredentials.WorkloadIdentityFederationConfig + var subjectTokens tokenprovider.TokenProvider + var err error + switch { + case wif.SubjectToken.SPIFFEJWTSVID != nil: + svid := wif.SubjectToken.SPIFFEJWTSVID + subjectTokens, err = tokenprovider.NewSPIFFETokenProvider(svid.SocketPath, svid.Audience) + case wif.SubjectToken.OIDCExchangeToken != nil: + subjectTokens, err = tokenprovider.NewOidcTokenProvider(ctx, c.client, &wif.SubjectToken.OIDCExchangeToken.OIDC, + bsp.Namespace, c.referenceGrantValidator.validateSecretReference) + default: + return nil, fmt.Errorf("one of spiffeJWTSVID or oidcExchangeToken must be defined, namespace %s name %s", bsp.Namespace, bsp.Name) + } + if err != nil { + return nil, fmt.Errorf("failed to initialize subject token provider: %w", err) + } + return tokenprovider.NewOpenAIWorkloadIdentityTokenProvider(tokenprovider.OpenAIWorkloadIdentityConfig{ + TokenURL: tokenprovider.OpenAITokenExchangeURL, + IdentityProviderID: wif.IdentityProviderID, + ServiceAccountID: wif.ServiceAccountID, + }, subjectTokens) +} + // getBackendSecurityPolicyAuthOIDC returns the backendSecurityPolicy's OIDC pointer or nil. func getBackendSecurityPolicyAuthOIDC(spec *aigv1b1.BackendSecurityPolicySpec) *egv1a1.OIDC { switch spec.Type { @@ -358,6 +394,10 @@ func getBackendSecurityPolicyAuthOIDC(spec *aigv1b1.BackendSecurityPolicySpec) * if spec.GCPCredentials != nil && spec.GCPCredentials.WorkloadIdentityFederationConfig != nil { return &spec.GCPCredentials.WorkloadIdentityFederationConfig.OIDCExchangeToken.OIDC } + case aigv1b1.BackendSecurityPolicyTypeOpenAICredentials: + if spec.OpenAICredentials != nil && spec.OpenAICredentials.WorkloadIdentityFederationConfig.SubjectToken.OIDCExchangeToken != nil { + return &spec.OpenAICredentials.WorkloadIdentityFederationConfig.SubjectToken.OIDCExchangeToken.OIDC + } } return nil } @@ -471,6 +511,8 @@ func getBSPGeneratedSecretName(bsp *aigv1b1.BackendSecurityPolicy) string { aigv1b1.BackendSecurityPolicyTypeAzureAPIKey, aigv1b1.BackendSecurityPolicyTypeAnthropicAPIKey: return "" // APIKey does not require rotation. + case aigv1b1.BackendSecurityPolicyTypeOpenAICredentials: + // The exchanged access token is always stored in a generated secret. default: panic("BUG: unsupported backend security policy type: " + string(bsp.Spec.Type)) } diff --git a/internal/controller/backend_security_policy_test.go b/internal/controller/backend_security_policy_test.go index b4a0024d8e..993b9ec5ca 100644 --- a/internal/controller/backend_security_policy_test.go +++ b/internal/controller/backend_security_policy_test.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "path/filepath" "testing" "time" @@ -39,6 +40,7 @@ import ( aigv1b1 "github.com/envoyproxy/ai-gateway/api/v1beta1" "github.com/envoyproxy/ai-gateway/internal/controller/rotators" + "github.com/envoyproxy/ai-gateway/internal/controller/tokenprovider" "github.com/envoyproxy/ai-gateway/internal/json" internaltesting "github.com/envoyproxy/ai-gateway/internal/testing" ) @@ -864,6 +866,129 @@ func TestNewBackendSecurityPolicyController_RotateCredentialInvalidType(t *testi require.Equal(t, time.Duration(0), res.RequeueAfter) } +func TestBackendSecurityPolicyController_RotateCredential_OpenAICredentials_OIDC(t *testing.T) { + var gotBody map[string]string + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"issuer": %q, "token_endpoint": %q, "authorization_endpoint": "authorization_endpoint", "jwks_uri": "jwks_uri", "scopes_supported": []}`, + server.URL, server.URL+"/oidc/token") + }) + mux.HandleFunc("/oidc/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token": "subject-token", "token_type": "Bearer", "expires_in": 3600}`)) + }) + mux.HandleFunc("/exchange", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + w.Header().Add("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token": "openai-access-token", "token_type": "Bearer", "expires_in": 3600, "expires_at": %d}`, time.Now().Add(time.Hour).Unix()) + }) + + origTokenURL := tokenprovider.OpenAITokenExchangeURL + tokenprovider.OpenAITokenExchangeURL = server.URL + "/exchange" + t.Cleanup(func() { tokenprovider.OpenAITokenExchangeURL = origTokenURL }) + + eventCh := internaltesting.NewControllerEventChan[*aigv1b1.AIServiceBackend]() + cl := fake.NewClientBuilder().WithScheme(Scheme).Build() + c := NewBackendSecurityPolicyController(cl, fake2.NewClientset(), ctrl.Log, eventCh.Ch, nil, DefaultPreRotationWindow) + const bspNamespace = "default" + require.NoError(t, cl.Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "oidc-client-secret", Namespace: bspNamespace}, + Data: map[string][]byte{"client-secret": []byte("client-secret")}, + })) + + bsp := &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "openai-oidc", Namespace: bspNamespace}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + OpenAICredentials: &aigv1b1.BackendSecurityPolicyOpenAICredentials{ + WorkloadIdentityFederationConfig: aigv1b1.OpenAIWorkloadIdentityFederationConfig{ + IdentityProviderID: "idp_123", + ServiceAccountID: "sa_456", + SubjectToken: aigv1b1.OpenAISubjectToken{ + OIDCExchangeToken: &aigv1b1.BackendSecurityPolicyOIDC{ + OIDC: egv1a1.OIDC{ + Provider: egv1a1.OIDCProvider{Issuer: server.URL}, + ClientID: ptr.To("some-client-id"), + ClientSecret: gwapiv1.SecretObjectReference{ + Name: "oidc-client-secret", + Namespace: ptr.To(gwapiv1.Namespace(bspNamespace)), + }, + }, + }, + }, + }, + }, + }, + } + require.NoError(t, cl.Create(t.Context(), bsp)) + + res, err := c.rotateCredential(t.Context(), bsp) + require.NoError(t, err) + require.Greater(t, res.RequeueAfter, 50*time.Minute) + + require.Equal(t, "subject-token", gotBody["subject_token"]) + require.Equal(t, "idp_123", gotBody["identity_provider_id"]) + require.Equal(t, "sa_456", gotBody["service_account_id"]) + + secret, err := rotators.LookupSecret(t.Context(), cl, bspNamespace, rotators.GetBSPSecretName("openai-oidc")) + require.NoError(t, err) + require.Equal(t, "openai-access-token", string(secret.Data[rotators.OpenAIAccessTokenKey])) + ok, _ := ctrlutil.HasOwnerReference(secret.OwnerReferences, bsp, c.client.Scheme()) + require.True(t, ok, "expected secret to have owner reference to BackendSecurityPolicy") +} + +func TestBackendSecurityPolicyController_Reconcile_OpenAICredentials_SPIFFEUnavailable(t *testing.T) { + eventCh := internaltesting.NewControllerEventChan[*aigv1b1.AIServiceBackend]() + cl := requireNewFakeClientWithIndexes(t) + c := NewBackendSecurityPolicyController(cl, fake2.NewClientset(), ctrl.Log, eventCh.Ch, nil, DefaultPreRotationWindow) + + bsp := &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "openai-bsp", Namespace: "default"}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + OpenAICredentials: &aigv1b1.BackendSecurityPolicyOpenAICredentials{ + WorkloadIdentityFederationConfig: aigv1b1.OpenAIWorkloadIdentityFederationConfig{ + IdentityProviderID: "idp_123", + ServiceAccountID: "sa_456", + SubjectToken: aigv1b1.OpenAISubjectToken{ + SPIFFEJWTSVID: &aigv1b1.BackendSecurityPolicySPIFFEJWTSVID{ + Audience: "https://auth.example.com", + SocketPath: "unix://" + filepath.Join(t.TempDir(), "missing.sock"), + }, + }, + }, + }, + }, + } + require.NoError(t, cl.Create(t.Context(), bsp)) + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + _, err := c.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "openai-bsp"}}) + require.ErrorContains(t, err, "failed to fetch JWT-SVID from SPIFFE Workload API") + + var updated aigv1b1.BackendSecurityPolicy + require.NoError(t, cl.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "openai-bsp"}, &updated)) + require.Len(t, updated.Status.Conditions, 1) + require.Equal(t, aigv1b1.ConditionTypeNotAccepted, updated.Status.Conditions[0].Type) +} + +func TestBackendSecurityPolicyController_RotateCredential_OpenAICredentials_NoSubjectToken(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(Scheme).Build() + c := NewBackendSecurityPolicyController(cl, fake2.NewClientset(), ctrl.Log, nil, nil, DefaultPreRotationWindow) + bsp := &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "openai-bsp", Namespace: "default"}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + OpenAICredentials: &aigv1b1.BackendSecurityPolicyOpenAICredentials{}, + }, + } + _, err := c.rotateCredential(t.Context(), bsp) + require.EqualError(t, err, "one of spiffeJWTSVID or oidcExchangeToken must be defined, namespace default name openai-bsp") +} + func TestNewBackendSecurityPolicyController_RotateCredentialAwsCredentialFile(t *testing.T) { eventCh := internaltesting.NewControllerEventChan[*aigv1b1.AIServiceBackend]() cl := fake.NewClientBuilder().WithScheme(Scheme).Build() @@ -1577,6 +1702,18 @@ func TestGetBSPGeneratedSecretName(t *testing.T) { }, expectedName: "", }, + { + name: "OpenAICredentials type", + bsp: &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openai-bsp", + }, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + }, + }, + expectedName: "ai-eg-bsp-openai-bsp", + }, } for _, tt := range tests { diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index f5e514c5a9..91db2732bf 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -843,6 +843,8 @@ func defaultOverrideHeaderName(t aigv1b1.BackendSecurityPolicyType) string { return "x-aigw-azure-access-token" case aigv1b1.BackendSecurityPolicyTypeGCPCredentials: return "x-aigw-gcp-access-token" + case aigv1b1.BackendSecurityPolicyTypeOpenAICredentials: + return "x-aigw-openai-access-token" case aigv1b1.BackendSecurityPolicyTypeAWSCredentials: return internalapi.AWSCredentialOverrideHeaderPrefix default: @@ -1006,6 +1008,18 @@ func (c *GatewayController) bspToFilterAPIBackendAuth(ctx context.Context, backe } hasStaticCred = true } + case aigv1b1.BackendSecurityPolicyTypeOpenAICredentials: + secretName := rotators.GetBSPSecretName(backendSecurityPolicy.Name) + openAIAccessToken, getErr := c.getSecretData(ctx, namespace, secretName, rotators.OpenAIAccessTokenKey) + if getErr != nil { + return nil, getErr + } + auth = &filterapi.BackendAuth{OpenAIAuth: &filterapi.OpenAIAuth{ + AccessToken: openAIAccessToken, + Organization: spec.OpenAICredentials.Organization, + Project: spec.OpenAICredentials.Project, + }} + hasStaticCred = true default: return nil, fmt.Errorf("invalid backend security type %s for policy %s", spec.Type, backendSecurityPolicy.Name) } diff --git a/internal/controller/gateway_test.go b/internal/controller/gateway_test.go index 5a8ea0b4d0..1b31913c99 100644 --- a/internal/controller/gateway_test.go +++ b/internal/controller/gateway_test.go @@ -1311,6 +1311,16 @@ func TestGatewayController_bspToFilterAPIBackendAuth(t *testing.T) { }, }, }, + { + ObjectMeta: metav1.ObjectMeta{Name: "openai-credentials", Namespace: namespace}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + OpenAICredentials: &aigv1b1.BackendSecurityPolicyOpenAICredentials{ + Organization: "org-123", + Project: "proj_456", + }, + }, + }, } { require.NoError(t, fakeClient.Create(t.Context(), bsp)) } @@ -1339,6 +1349,10 @@ func TestGatewayController_bspToFilterAPIBackendAuth(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: rotators.GetBSPSecretName("gcp-wif"), Namespace: namespace}, StringData: map[string]string{rotators.GCPAccessTokenKey: "thisisgcpcredentials"}, }, + { + ObjectMeta: metav1.ObjectMeta{Name: rotators.GetBSPSecretName("openai-credentials"), Namespace: namespace}, + StringData: map[string]string{rotators.OpenAIAccessTokenKey: "thisisopenaicredentials"}, + }, } { _, err := kube.CoreV1().Secrets(namespace).Create(t.Context(), s, metav1.CreateOptions{}) require.NoError(t, err) @@ -1402,6 +1416,16 @@ func TestGatewayController_bspToFilterAPIBackendAuth(t *testing.T) { AnthropicAPIKey: &filterapi.AnthropicAPIKeyAuth{Key: "thisisapikey"}, }, }, + { + bspName: "openai-credentials", + exp: &filterapi.BackendAuth{ + OpenAIAuth: &filterapi.OpenAIAuth{ + AccessToken: "thisisopenaicredentials", + Organization: "org-123", + Project: "proj_456", + }, + }, + }, } { t.Run(tc.bspName, func(t *testing.T) { bsp := &aigv1b1.BackendSecurityPolicy{} @@ -1500,6 +1524,18 @@ func TestGatewayController_bspToFilterAPIBackendAuth_ErrorCases(t *testing.T) { }, expectedError: "failed to get secret test-namespace/missing-aws-secret", }, + { + name: "openai credentials with missing generated secret", + bspName: "openai-bsp", + bsp: &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "openai-bsp", Namespace: namespace}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + OpenAICredentials: &aigv1b1.BackendSecurityPolicyOpenAICredentials{}, + }, + }, + expectedError: "failed to get secret test-namespace/ai-eg-bsp-openai-bsp", + }, } for _, tt := range tests { @@ -1727,6 +1763,19 @@ func TestResolveCredentialOverride(t *testing.T) { require.Equal(t, "x-aigw-gcp-access-token", result.DynamicMetadataKey) }) + t.Run("fromRequestHeaders default header for OpenAICredentials", func(t *testing.T) { + result, err := resolveCredentialOverride( + aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + &aigv1b1.BackendSecurityPolicyCredentialOverride{ + FromRequestHeaders: &aigv1b1.CredentialOverrideFromRequestHeaders{}, + }, + true, + ) + require.NoError(t, err) + require.Equal(t, "x-aigw-openai-access-token", result.HeaderName) + require.Equal(t, []string{"x-aigw-openai-access-token"}, result.InputHeadersToRemove) + }) + t.Run("fromRequestHeaders for AWSCredentials derives three headers from a prefix", func(t *testing.T) { result, err := resolveCredentialOverride( aigv1b1.BackendSecurityPolicyTypeAWSCredentials, diff --git a/internal/controller/rotators/openai_token_rotator.go b/internal/controller/rotators/openai_token_rotator.go new file mode 100644 index 0000000000..7eac7c6695 --- /dev/null +++ b/internal/controller/rotators/openai_token_rotator.go @@ -0,0 +1,143 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package rotators + +import ( + "context" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/envoyproxy/ai-gateway/internal/controller/tokenprovider" +) + +const ( + // OpenAIAccessTokenKey is the key used to store OpenAI access token in Kubernetes secrets. + OpenAIAccessTokenKey = "openAIAccessToken" +) + +// openAITokenRotator implements Rotator interface for OpenAI access token exchange. +type openAITokenRotator struct { + // client is used for Kubernetes API operations. + client client.Client + // kube provides additional API capabilities. + kube kubernetes.Interface + // logger is used for structured logging. + logger logr.Logger + // backendSecurityPolicyName provides name of backend security policy. + backendSecurityPolicyName string + // backendSecurityPolicyNamespace provides namespace of backend security policy. + backendSecurityPolicyNamespace string + // preRotationWindow specifies how long before expiry to rotate. + preRotationWindow time.Duration + // tokenProvider specifies provider to fetch OpenAI access token. + tokenProvider tokenprovider.TokenProvider +} + +// NewOpenAITokenRotator creates a new openAITokenRotator with the given parameters. +func NewOpenAITokenRotator( + client client.Client, + kube kubernetes.Interface, + logger logr.Logger, + backendSecurityPolicyNamespace string, + backendSecurityPolicyName string, + preRotationWindow time.Duration, + tokenProvider tokenprovider.TokenProvider, +) (Rotator, error) { + return &openAITokenRotator{ + client: client, + kube: kube, + logger: logger.WithName("openai-token-rotator"), + backendSecurityPolicyNamespace: backendSecurityPolicyNamespace, + backendSecurityPolicyName: backendSecurityPolicyName, + preRotationWindow: preRotationWindow, + tokenProvider: tokenProvider, + }, nil +} + +// IsExpired implements Rotator.IsExpired method to check if the preRotation time is before the current time. +func (r *openAITokenRotator) IsExpired(preRotationExpirationTime time.Time) bool { + return IsBufferedTimeExpired(0, preRotationExpirationTime) +} + +// GetPreRotationTime implements Rotator.GetPreRotationTime method to retrieve the pre-rotation time for OpenAI token. +func (r *openAITokenRotator) GetPreRotationTime(ctx context.Context) (time.Time, error) { + secret, err := LookupSecret(ctx, r.client, r.backendSecurityPolicyNamespace, GetBSPSecretName(r.backendSecurityPolicyName)) + if err != nil { + if apierrors.IsNotFound(err) { + return time.Time{}, nil + } + return time.Time{}, err + } + expirationTime, err := GetExpirationSecretAnnotation(secret) + if err != nil { + return time.Time{}, err + } + preRotationTime := expirationTime.Add(-r.preRotationWindow) + return preRotationTime, nil +} + +// Rotate implements Rotator.Rotate method to rotate OpenAI access token and updates the Kubernetes secret. +func (r *openAITokenRotator) Rotate(ctx context.Context) (time.Time, error) { + bspNamespace := r.backendSecurityPolicyNamespace + bspName := r.backendSecurityPolicyName + secretName := GetBSPSecretName(bspName) + + r.logger.Info("start rotating OpenAI access token", "namespace", bspNamespace, "name", bspName) + + openAIToken, err := r.tokenProvider.GetToken(ctx) + if err != nil { + r.logger.Error(err, "failed to get access token via token exchange") + return time.Time{}, err + } + secret, err := LookupSecret(ctx, r.client, bspNamespace, secretName) + if err != nil { + if apierrors.IsNotFound(err) { + r.logger.Info("creating a new OpenAI access token into secret", "namespace", bspNamespace, "name", bspName) + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: bspNamespace, + }, + Type: corev1.SecretTypeOpaque, + Data: make(map[string][]byte), + } + populateOpenAIAccessToken(secret, &openAIToken) + err = r.client.Create(ctx, secret) + if err != nil { + r.logger.Error(err, "failed to create OpenAI access token", "namespace", bspNamespace, "name", bspName) + return time.Time{}, err + } + return openAIToken.ExpiresAt, nil + } + r.logger.Error(err, "failed to lookup OpenAI access token secret", "namespace", bspNamespace, "name", bspName) + return time.Time{}, err + } + r.logger.Info("updating OpenAI access token secret", "namespace", bspNamespace, "name", bspName) + + populateOpenAIAccessToken(secret, &openAIToken) + err = r.client.Update(ctx, secret) + if err != nil { + r.logger.Error(err, "failed to update OpenAI access token", "namespace", bspNamespace, "name", bspName) + return time.Time{}, err + } + return openAIToken.ExpiresAt, nil +} + +// populateOpenAIAccessToken updates the secret with the OpenAI access token. +func populateOpenAIAccessToken(secret *corev1.Secret, token *tokenprovider.TokenExpiry) { + updateExpirationSecretAnnotation(secret, token.ExpiresAt) + + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + secret.Data[OpenAIAccessTokenKey] = []byte(token.Token) +} diff --git a/internal/controller/rotators/openai_token_rotator_test.go b/internal/controller/rotators/openai_token_rotator_test.go new file mode 100644 index 0000000000..ec259923da --- /dev/null +++ b/internal/controller/rotators/openai_token_rotator_test.go @@ -0,0 +1,243 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package rotators + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/envoyproxy/ai-gateway/internal/controller/tokenprovider" +) + +func TestOpenAITokenRotator_Rotate(t *testing.T) { + scheme := runtime.NewScheme() + scheme.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Secret{}) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + t.Run("failed to get openai token", func(t *testing.T) { + now := time.Now() + oneHourBeforeNow := now.Add(-1 * time.Hour) + twoHourAfterNow := now.Add(2 * time.Hour) + mockProvider := tokenprovider.NewMockTokenProvider("fake-token", twoHourAfterNow, fmt.Errorf("failed to get OpenAI access token")) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetBSPSecretName("test-policy"), + Namespace: "default", + Annotations: map[string]string{ + ExpirationTimeAnnotationKey: oneHourBeforeNow.Format(time.RFC3339), + }, + }, + Data: map[string][]byte{ + OpenAIAccessTokenKey: []byte("some-openai-access-token"), + }, + } + err := client.Create(context.Background(), secret) + require.NoError(t, err) + + rotator := &openAITokenRotator{ + client: client, + backendSecurityPolicyNamespace: "default", + backendSecurityPolicyName: "test-policy", + preRotationWindow: 5 * time.Minute, + tokenProvider: mockProvider, + } + + _, err = rotator.Rotate(context.Background()) + require.Error(t, err) + err = client.Delete(context.Background(), secret) + require.NoError(t, err) + }) + + t.Run("secret does not exist", func(t *testing.T) { + now := time.Now() + twoHourAfterNow := now.Add(2 * time.Hour) + mockProvider := tokenprovider.NewMockTokenProvider("fake-token", twoHourAfterNow, nil) + + rotator := &openAITokenRotator{ + client: client, + + backendSecurityPolicyNamespace: "default", + backendSecurityPolicyName: "test-policy", + preRotationWindow: 5 * time.Minute, + tokenProvider: mockProvider, + } + expiration, err := rotator.Rotate(context.Background()) + require.NoError(t, err) + secret, err := LookupSecret(context.Background(), client, "default", GetBSPSecretName("test-policy")) + require.NoError(t, err) + err = client.Delete(context.Background(), secret) + require.NoError(t, err) + require.Equal(t, twoHourAfterNow, expiration) + }) + + t.Run("secret exist", func(t *testing.T) { + now := time.Now() + twoHourAfterNow := now.Add(2 * time.Hour) + oneHourBeforeNow := now.Add(-1 * time.Hour) + mockProvider := tokenprovider.NewMockTokenProvider("fake-token", twoHourAfterNow, nil) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetBSPSecretName("test-policy"), + Namespace: "default", + Annotations: map[string]string{ + ExpirationTimeAnnotationKey: oneHourBeforeNow.Format(time.RFC3339), + }, + }, + Data: map[string][]byte{ + OpenAIAccessTokenKey: []byte("some-openai-access-token"), + }, + } + err := client.Create(context.Background(), secret) + require.NoError(t, err) + + rotator := &openAITokenRotator{ + client: client, + tokenProvider: mockProvider, + backendSecurityPolicyNamespace: "default", + backendSecurityPolicyName: "test-policy", + preRotationWindow: 5 * time.Minute, + } + + expiration, err := rotator.Rotate(context.Background()) + require.NoError(t, err) + require.Equal(t, twoHourAfterNow, expiration) + + err = client.Delete(context.Background(), secret) + require.NoError(t, err) + }) +} + +func TestOpenAITokenRotator_GetPreRotationTime(t *testing.T) { + scheme := runtime.NewScheme() + scheme.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Secret{}) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + rotator := &openAITokenRotator{ + client: client, + preRotationWindow: 5 * time.Minute, + backendSecurityPolicyNamespace: "default", + backendSecurityPolicyName: "test-policy", + } + + now := time.Now() + + tests := []struct { + name string + secret *corev1.Secret + expectedTime time.Time + expectedError bool + }{ + { + name: "secret annotation missing", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetBSPSecretName("test-policy"), + Namespace: "default", + }, + Data: map[string][]byte{ + OpenAIAccessTokenKey: []byte("some-openai-access-token"), + }, + }, + expectedTime: time.Time{}, + expectedError: true, + }, + { + name: "rotation time before expiration time", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetBSPSecretName("test-policy"), + Namespace: "default", + Annotations: map[string]string{ + ExpirationTimeAnnotationKey: now.Add(2 * time.Hour).Format(time.RFC3339), + }, + }, + Data: map[string][]byte{ + OpenAIAccessTokenKey: []byte("some-openai-access-token"), + }, + }, + expectedTime: now.Add(2 * time.Hour), + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := client.Create(context.Background(), tt.secret) + require.NoError(t, err) + + got, err := rotator.GetPreRotationTime(context.Background()) + if (err != nil) != tt.expectedError { + t.Errorf("OpenAITokenRotator.GetPreRotationTime() error = %v, expectedError %v", err, tt.expectedError) + return + } + if !tt.expectedTime.IsZero() && got.Compare(tt.expectedTime) >= 0 { + t.Errorf("OpenAITokenRotator.GetPreRotationTime() = %v, expected %v", got, tt.expectedTime) + } + err = client.Delete(context.Background(), tt.secret) + require.NoError(t, err) + }) + } +} + +func TestOpenAITokenRotator_IsExpired(t *testing.T) { + scheme := runtime.NewScheme() + scheme.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Secret{}) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + rotator := &openAITokenRotator{ + client: client, + } + tests := []struct { + name string + expiration time.Time + expect bool + }{ + { + name: "not expired", + expiration: time.Now().Add(1 * time.Hour), + expect: false, + }, + { + name: "expired", + expiration: time.Now().Add(-1 * time.Hour), + expect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rotator.IsExpired(tt.expiration); got != tt.expect { + t.Errorf("OpenAITokenRotator.IsExpired() = %v, expect %v", got, tt.expect) + } + }) + } +} + +func TestPopulateOpenAIAccessToken(t *testing.T) { + secret := &corev1.Secret{} + expiration := time.Now() + + openAIToken := tokenprovider.TokenExpiry{Token: "some-openai-token", ExpiresAt: expiration} + populateOpenAIAccessToken(secret, &openAIToken) + + annotation, ok := secret.Annotations[ExpirationTimeAnnotationKey] + require.True(t, ok) + require.Equal(t, expiration.Format(time.RFC3339), annotation) + + require.Len(t, secret.Data, 1) + val, ok := secret.Data[OpenAIAccessTokenKey] + require.True(t, ok) + require.Equal(t, "some-openai-token", string(val)) +} diff --git a/internal/controller/tokenprovider/openai_workload_identity_token_provider.go b/internal/controller/tokenprovider/openai_workload_identity_token_provider.go new file mode 100644 index 0000000000..c96c12b1f1 --- /dev/null +++ b/internal/controller/tokenprovider/openai_workload_identity_token_provider.go @@ -0,0 +1,182 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package tokenprovider + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "time" + + "github.com/envoyproxy/ai-gateway/internal/json" +) + +const ( + // tokenExchangeGrantType is the RFC 8693 grant type for token exchange requests. + tokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" // #nosec G101 + // jwtTokenType is the RFC 8693 token type of a JWT subject token. + jwtTokenType = "urn:ietf:params:oauth:token-type:jwt" // #nosec G101 + // maxErrorBodyLength caps how much of an unrecognized error response is included in errors. + maxErrorBodyLength = 512 + // OpenAIProxyEnvVar is the environment variable name for configuring the proxy URL of OpenAI token + // exchange requests. + OpenAIProxyEnvVar = "AI_GATEWAY_OPENAI_PROXY_URL" +) + +// OpenAITokenExchangeURL is the OpenAI token exchange endpoint. It is not configurable through the API so that +// policies cannot direct the controller to send subject tokens to arbitrary URLs. It is a variable only so +// tests can point it at a local server. +var OpenAITokenExchangeURL = "https://auth.openai.com/oauth/token" // #nosec G101 + +// OpenAIWorkloadIdentityConfig holds the parameters of an OpenAI Workload Identity Federation token exchange. +type OpenAIWorkloadIdentityConfig struct { + // TokenURL is the OpenAI token exchange endpoint. + TokenURL string + // IdentityProviderID is the OpenAI Workload Identity Provider ID. + IdentityProviderID string + // ServiceAccountID is the OpenAI service account ID. + ServiceAccountID string +} + +// openAIWorkloadIdentityTokenProvider is a provider implements TokenProvider interface for OpenAI +// Workload Identity Federation. +type openAIWorkloadIdentityTokenProvider struct { + config OpenAIWorkloadIdentityConfig + subjectTokens TokenProvider + httpClient *http.Client +} + +// openAITokenExchangeRequest is the JSON body of an OpenAI token exchange request. +type openAITokenExchangeRequest struct { + GrantType string `json:"grant_type"` + SubjectToken string `json:"subject_token"` + SubjectTokenType string `json:"subject_token_type"` + IdentityProviderID string `json:"identity_provider_id"` + ServiceAccountID string `json:"service_account_id"` +} + +// openAITokenExchangeResponse is the successful response of an OpenAI token exchange. +type openAITokenExchangeResponse struct { + AccessToken string `json:"access_token"` + // ExpiresAt is the expiration time of the access token in Unix seconds. + ExpiresAt int64 `json:"expires_at"` +} + +// openAIErrorResponse is the error response of the OpenAI API. +type openAIErrorResponse struct { + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` +} + +// NewOpenAIWorkloadIdentityTokenProvider creates a new TokenProvider that obtains a JWT subject token from +// subjectTokens and exchanges it for an OpenAI access token at config.TokenURL. +// +// https://developers.openai.com/api/reference/workload-identity-federation +func NewOpenAIWorkloadIdentityTokenProvider(config OpenAIWorkloadIdentityConfig, subjectTokens TokenProvider) (TokenProvider, error) { + if config.TokenURL == "" { + return nil, errors.New("token exchange URL must not be empty") + } + if config.IdentityProviderID == "" { + return nil, errors.New("identity provider ID must not be empty") + } + if config.ServiceAccountID == "" { + return nil, errors.New("service account ID must not be empty") + } + if subjectTokens == nil { + return nil, errors.New("subject token provider must not be nil") + } + transport, err := newOpenAITransport() + if err != nil { + return nil, err + } + return &openAIWorkloadIdentityTokenProvider{ + config: config, + subjectTokens: subjectTokens, + httpClient: &http.Client{Timeout: time.Minute, Transport: transport}, + }, nil +} + +// newOpenAITransport returns an http.RoundTripper that uses the proxy from AI_GATEWAY_OPENAI_PROXY_URL +// if set, and otherwise the default transport's proxy settings. +func newOpenAITransport() (http.RoundTripper, error) { + t := http.DefaultTransport.(*http.Transport).Clone() + if proxyURLStr := os.Getenv(OpenAIProxyEnvVar); proxyURLStr != "" { + proxyURL, err := url.Parse(proxyURLStr) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", OpenAIProxyEnvVar, err) + } + t.Proxy = http.ProxyURL(proxyURL) + } + return t, nil +} + +// GetToken implements TokenProvider.GetToken method to retrieve an OpenAI access token and its expiration time. +// +// The expiration time is taken from expires_at. +func (t *openAIWorkloadIdentityTokenProvider) GetToken(ctx context.Context) (TokenExpiry, error) { + subject, err := t.subjectTokens.GetToken(ctx) + if err != nil { + return TokenExpiry{}, fmt.Errorf("failed to get subject token: %w", err) + } + + reqBody, err := json.Marshal(openAITokenExchangeRequest{ + GrantType: tokenExchangeGrantType, + SubjectToken: subject.Token, + SubjectTokenType: jwtTokenType, + IdentityProviderID: t.config.IdentityProviderID, + ServiceAccountID: t.config.ServiceAccountID, + }) + if err != nil { + return TokenExpiry{}, fmt.Errorf("failed to encode token exchange request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.config.TokenURL, bytes.NewReader(reqBody)) + if err != nil { + return TokenExpiry{}, fmt.Errorf("failed to create token exchange request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := t.httpClient.Do(req) + if err != nil { + return TokenExpiry{}, fmt.Errorf("failed to send token exchange request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return TokenExpiry{}, fmt.Errorf("failed to read token exchange response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp openAIErrorResponse + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + return TokenExpiry{}, fmt.Errorf("token exchange failed with status %d: %s: %s", + resp.StatusCode, errResp.Error.Code, errResp.Error.Message) + } + if len(body) > maxErrorBodyLength { + body = body[:maxErrorBodyLength] + } + return TokenExpiry{}, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body) + } + + var tokenResp openAITokenExchangeResponse + if err = json.Unmarshal(body, &tokenResp); err != nil { + return TokenExpiry{}, fmt.Errorf("failed to decode token exchange response: %w", err) + } + if tokenResp.AccessToken == "" { + return TokenExpiry{}, errors.New("token exchange response has no access_token") + } + if tokenResp.ExpiresAt <= 0 { + return TokenExpiry{}, errors.New("token exchange response has no expires_at") + } + return TokenExpiry{Token: tokenResp.AccessToken, ExpiresAt: time.Unix(tokenResp.ExpiresAt, 0)}, nil +} diff --git a/internal/controller/tokenprovider/openai_workload_identity_token_provider_test.go b/internal/controller/tokenprovider/openai_workload_identity_token_provider_test.go new file mode 100644 index 0000000000..56330d132c --- /dev/null +++ b/internal/controller/tokenprovider/openai_workload_identity_token_provider_test.go @@ -0,0 +1,236 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package tokenprovider + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/envoyproxy/ai-gateway/internal/json" +) + +func testOpenAIWorkloadIdentityConfig(tokenURL string) OpenAIWorkloadIdentityConfig { + return OpenAIWorkloadIdentityConfig{TokenURL: tokenURL, IdentityProviderID: "idp_123", ServiceAccountID: "sa_456"} +} + +func TestNewOpenAIWorkloadIdentityTokenProvider(t *testing.T) { + subject := NewMockTokenProvider("subject", time.Now(), nil) + for _, tc := range []struct { + name string + config OpenAIWorkloadIdentityConfig + subject TokenProvider + expErr string + }{ + { + name: "valid", + config: testOpenAIWorkloadIdentityConfig("https://auth.openai.com/oauth/token"), + subject: subject, + }, + { + name: "empty token URL", + config: OpenAIWorkloadIdentityConfig{IdentityProviderID: "idp_123", ServiceAccountID: "sa_456"}, + subject: subject, + expErr: "token exchange URL must not be empty", + }, + { + name: "empty identity provider ID", + config: OpenAIWorkloadIdentityConfig{TokenURL: "https://auth.openai.com/oauth/token", ServiceAccountID: "sa_456"}, + subject: subject, + expErr: "identity provider ID must not be empty", + }, + { + name: "empty service account ID", + config: OpenAIWorkloadIdentityConfig{TokenURL: "https://auth.openai.com/oauth/token", IdentityProviderID: "idp_123"}, + subject: subject, + expErr: "service account ID must not be empty", + }, + { + name: "nil subject token provider", + config: testOpenAIWorkloadIdentityConfig("https://auth.openai.com/oauth/token"), + expErr: "subject token provider must not be nil", + }, + } { + t.Run(tc.name, func(t *testing.T) { + provider, err := NewOpenAIWorkloadIdentityTokenProvider(tc.config, tc.subject) + if tc.expErr != "" { + require.EqualError(t, err, tc.expErr) + require.Nil(t, provider) + return + } + require.NoError(t, err) + require.NotNil(t, provider) + }) + } +} + +func TestNewOpenAIWorkloadIdentityTokenProvider_InvalidProxyURL(t *testing.T) { + t.Setenv(OpenAIProxyEnvVar, "://bad") + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig("https://auth.openai.com/oauth/token"), + NewMockTokenProvider("subject", time.Now(), nil)) + require.ErrorContains(t, err, "invalid AI_GATEWAY_OPENAI_PROXY_URL") + require.Nil(t, provider) +} + +func TestOpenAIWorkloadIdentityTokenProvider_GetToken_Proxy(t *testing.T) { + var gotHost string + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A forward proxy receives the absolute request URL of the token endpoint. + gotHost = r.URL.Host + _, _ = fmt.Fprintf(w, `{"access_token":"exchanged","expires_in":600,"expires_at":%d}`, time.Now().Add(10*time.Minute).Unix()) + })) + defer proxy.Close() + t.Setenv(OpenAIProxyEnvVar, proxy.URL) + + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig("http://auth.openai.invalid/oauth/token"), + NewMockTokenProvider("subject", time.Now().Add(time.Hour), nil)) + require.NoError(t, err) + token, err := provider.GetToken(t.Context()) + require.NoError(t, err) + require.Equal(t, "exchanged", token.Token) + require.Equal(t, "auth.openai.invalid", gotHost) +} + +func TestOpenAIWorkloadIdentityTokenProvider_GetToken(t *testing.T) { + subjectExpiry := time.Now().Add(time.Hour).Truncate(time.Second) + + t.Run("success", func(t *testing.T) { + expiresAt := time.Now().Add(30 * time.Minute).Truncate(time.Second) + var gotBody map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &gotBody)) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":"exchanged","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":600,"expires_at":%d}`, expiresAt.Unix()) + })) + defer server.Close() + + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig(server.URL), + NewMockTokenProvider("subject", subjectExpiry, nil)) + require.NoError(t, err) + + token, err := provider.GetToken(t.Context()) + require.NoError(t, err) + require.Equal(t, "exchanged", token.Token) + require.True(t, expiresAt.Equal(token.ExpiresAt), "expected %v, got %v", expiresAt, token.ExpiresAt) + require.Equal(t, map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": "subject", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "identity_provider_id": "idp_123", + "service_account_id": "sa_456", + }, gotBody) + }) + + t.Run("subject token error", func(t *testing.T) { + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig("http://127.0.0.1:0"), + NewMockTokenProvider("", time.Time{}, errors.New("workload API unavailable"))) + require.NoError(t, err) + _, err = provider.GetToken(t.Context()) + require.EqualError(t, err, "failed to get subject token: workload API unavailable") + }) + + for _, tc := range []struct { + name string + status int + body string + expErr string + }{ + { + name: "openai error response", + status: http.StatusUnauthorized, + body: `{"error":{"message":"No identity provider found for the given provider id.","type":"invalid_request_error","param":null,"code":"invalid_grant"}}`, + expErr: "token exchange failed with status 401: invalid_grant: No identity provider found for the given provider id.", + }, + { + name: "unrecognized error response", + status: http.StatusBadRequest, + body: `{"error":"invalid_grant","error_description":"subject token expired"}`, + expErr: `token exchange failed with status 400: {"error":"invalid_grant","error_description":"subject token expired"}`, + }, + { + name: "non-json error response", + status: http.StatusBadGateway, + body: `bad gateway`, + expErr: "token exchange failed with status 502: bad gateway", + }, + { + name: "invalid json", + status: http.StatusOK, + body: `{`, + expErr: "failed to decode token exchange response", + }, + { + name: "missing access token", + status: http.StatusOK, + body: `{"expires_in":600,"expires_at":1793053844}`, + expErr: "token exchange response has no access_token", + }, + { + name: "missing expires_at", + status: http.StatusOK, + body: `{"access_token":"exchanged","expires_in":600}`, + expErr: "token exchange response has no expires_at", + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig(server.URL), + NewMockTokenProvider("subject", subjectExpiry, nil)) + require.NoError(t, err) + _, err = provider.GetToken(t.Context()) + require.ErrorContains(t, err, tc.expErr) + }) + } + + t.Run("long error response is truncated", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(strings.Repeat("x", maxErrorBodyLength+100))) + })) + defer server.Close() + + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig(server.URL), + NewMockTokenProvider("subject", subjectExpiry, nil)) + require.NoError(t, err) + _, err = provider.GetToken(t.Context()) + require.EqualError(t, err, "token exchange failed with status 502: "+strings.Repeat("x", maxErrorBodyLength)) + }) + + t.Run("request error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + server.Close() + + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig(server.URL), + NewMockTokenProvider("subject", subjectExpiry, nil)) + require.NoError(t, err) + _, err = provider.GetToken(t.Context()) + require.ErrorContains(t, err, "failed to send token exchange request") + }) + + t.Run("invalid token URL", func(t *testing.T) { + provider, err := NewOpenAIWorkloadIdentityTokenProvider(testOpenAIWorkloadIdentityConfig("://bad"), + NewMockTokenProvider("subject", subjectExpiry, nil)) + require.NoError(t, err) + _, err = provider.GetToken(t.Context()) + require.ErrorContains(t, err, "failed to create token exchange request") + }) +} diff --git a/internal/controller/tokenprovider/spiffe_token_provider.go b/internal/controller/tokenprovider/spiffe_token_provider.go new file mode 100644 index 0000000000..7a2ecd8a0c --- /dev/null +++ b/internal/controller/tokenprovider/spiffe_token_provider.go @@ -0,0 +1,58 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package tokenprovider + +import ( + "context" + "errors" + "fmt" + + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "github.com/spiffe/go-spiffe/v2/workloadapi" +) + +// jwtSVIDFetcher fetches a JWT-SVID for the given audience from the SPIFFE Workload API. +type jwtSVIDFetcher func(ctx context.Context, audience string) (TokenExpiry, error) + +// spiffeTokenProvider is a provider implements TokenProvider interface for SPIFFE JWT-SVIDs. +type spiffeTokenProvider struct { + audience string + fetch jwtSVIDFetcher +} + +// NewSPIFFETokenProvider creates a new TokenProvider that fetches JWT-SVIDs with the given audience +// from the SPIFFE Workload API. +// +// socketAddr is the Workload API address, e.g. "unix:///run/spire/sockets/agent.sock". When empty, +// the SPIFFE_ENDPOINT_SOCKET environment variable is used. +func NewSPIFFETokenProvider(socketAddr, audience string) (TokenProvider, error) { + if audience == "" { + return nil, errors.New("SPIFFE JWT-SVID audience must not be empty") + } + var opts []workloadapi.ClientOption + if socketAddr != "" { + opts = append(opts, workloadapi.WithAddr(socketAddr)) + } + return &spiffeTokenProvider{ + audience: audience, + fetch: func(ctx context.Context, audience string) (TokenExpiry, error) { + svid, err := workloadapi.FetchJWTSVID(ctx, jwtsvid.Params{Audience: audience}, opts...) + if err != nil { + return TokenExpiry{}, err + } + return TokenExpiry{Token: svid.Marshal(), ExpiresAt: svid.Expiry}, nil + }, + }, nil +} + +// GetToken implements TokenProvider.GetToken method to retrieve a JWT-SVID and its expiration time. +func (s *spiffeTokenProvider) GetToken(ctx context.Context) (TokenExpiry, error) { + token, err := s.fetch(ctx, s.audience) + if err != nil { + return TokenExpiry{}, fmt.Errorf("failed to fetch JWT-SVID from SPIFFE Workload API: %w", err) + } + return token, nil +} diff --git a/internal/controller/tokenprovider/spiffe_token_provider_test.go b/internal/controller/tokenprovider/spiffe_token_provider_test.go new file mode 100644 index 0000000000..cd7399701d --- /dev/null +++ b/internal/controller/tokenprovider/spiffe_token_provider_test.go @@ -0,0 +1,162 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package tokenprovider + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const testSPIFFEID = "spiffe://example.org/ns/envoy-ai-gateway-system/sa/ai-gateway-controller" + +func TestNewSPIFFETokenProvider(t *testing.T) { + t.Run("empty audience", func(t *testing.T) { + provider, err := NewSPIFFETokenProvider("unix:///tmp/agent.sock", "") + require.ErrorContains(t, err, "audience must not be empty") + require.Nil(t, provider) + }) + + t.Run("valid", func(t *testing.T) { + provider, err := NewSPIFFETokenProvider("unix:///tmp/agent.sock", "openai") + require.NoError(t, err) + require.IsType(t, &spiffeTokenProvider{}, provider) + }) + + t.Run("empty socket address", func(t *testing.T) { + provider, err := NewSPIFFETokenProvider("", "openai") + require.NoError(t, err) + require.IsType(t, &spiffeTokenProvider{}, provider) + }) +} + +func TestSPIFFETokenProvider_GetToken(t *testing.T) { + t.Run("success", func(t *testing.T) { + expiresAt := time.Now().Add(5 * time.Minute) + var gotAudience string + provider := &spiffeTokenProvider{ + audience: "openai", + fetch: func(_ context.Context, audience string) (TokenExpiry, error) { + gotAudience = audience + return TokenExpiry{Token: "svid", ExpiresAt: expiresAt}, nil + }, + } + token, err := provider.GetToken(t.Context()) + require.NoError(t, err) + require.Equal(t, "openai", gotAudience) + require.Equal(t, TokenExpiry{Token: "svid", ExpiresAt: expiresAt}, token) + }) + + t.Run("fetch error", func(t *testing.T) { + provider := &spiffeTokenProvider{ + audience: "openai", + fetch: func(context.Context, string) (TokenExpiry, error) { + return TokenExpiry{}, errors.New("connection refused") + }, + } + _, err := provider.GetToken(t.Context()) + require.ErrorContains(t, err, "failed to fetch JWT-SVID from SPIFFE Workload API: connection refused") + }) +} + +func TestSPIFFETokenProvider_GetToken_WorkloadAPI(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + expiresAt := time.Now().Add(5 * time.Minute).Truncate(time.Second).UTC() + + server := &fakeWorkloadAPIServer{key: key, expiresAt: expiresAt} + addr := startFakeWorkloadAPI(t, server) + + t.Run("explicit socket address", func(t *testing.T) { + provider, err := NewSPIFFETokenProvider(addr, "openai") + require.NoError(t, err) + token, err := provider.GetToken(t.Context()) + require.NoError(t, err) + require.Equal(t, expiresAt, token.ExpiresAt) + require.Equal(t, []string{"openai"}, server.lastAudience) + + claims := jwt.RegisteredClaims{} + _, err = jwt.ParseWithClaims(token.Token, &claims, func(*jwt.Token) (any, error) { return &key.PublicKey, nil }) + require.NoError(t, err) + require.Equal(t, testSPIFFEID, claims.Subject) + require.Equal(t, jwt.ClaimStrings{"openai"}, claims.Audience) + }) + + t.Run("socket address from SPIFFE_ENDPOINT_SOCKET", func(t *testing.T) { + t.Setenv("SPIFFE_ENDPOINT_SOCKET", addr) + provider, err := NewSPIFFETokenProvider("", "other-audience") + require.NoError(t, err) + token, err := provider.GetToken(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, token.Token) + require.Equal(t, []string{"other-audience"}, server.lastAudience) + }) + + t.Run("workload API error", func(t *testing.T) { + provider, err := NewSPIFFETokenProvider(addr, "denied") + require.NoError(t, err) + _, err = provider.GetToken(t.Context()) + require.ErrorContains(t, err, "failed to fetch JWT-SVID from SPIFFE Workload API") + require.ErrorContains(t, err, "no identity issued") + }) +} + +// fakeWorkloadAPIServer issues JWT-SVIDs signed by key for testSPIFFEID. +type fakeWorkloadAPIServer struct { + workload.UnimplementedSpiffeWorkloadAPIServer + key *ecdsa.PrivateKey + expiresAt time.Time + lastAudience []string +} + +func (f *fakeWorkloadAPIServer) FetchJWTSVID(_ context.Context, req *workload.JWTSVIDRequest) (*workload.JWTSVIDResponse, error) { + f.lastAudience = req.Audience + if len(req.Audience) == 1 && req.Audience[0] == "denied" { + return nil, status.Error(codes.PermissionDenied, "no identity issued") + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.RegisteredClaims{ + Subject: testSPIFFEID, + Audience: req.Audience, + ExpiresAt: jwt.NewNumericDate(f.expiresAt), + }) + signed, err := token.SignedString(f.key) + if err != nil { + return nil, err + } + return &workload.JWTSVIDResponse{Svids: []*workload.JWTSVID{{SpiffeId: testSPIFFEID, Svid: signed}}}, nil +} + +// startFakeWorkloadAPI serves the given server on a unix socket and returns its address. +func startFakeWorkloadAPI(t *testing.T, server workload.SpiffeWorkloadAPIServer) string { + t.Helper() + // Use a short directory since unix socket paths are limited to ~104 bytes on macOS. + dir, err := os.MkdirTemp("", "spiffe") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socketPath := filepath.Join(dir, "agent.sock") + + lis, err := net.Listen("unix", socketPath) + require.NoError(t, err) + s := grpc.NewServer() + workload.RegisterSpiffeWorkloadAPIServer(s, server) + go func() { _ = s.Serve(lis) }() + t.Cleanup(s.Stop) + return "unix://" + socketPath +} diff --git a/internal/filterapi/filterconfig.go b/internal/filterapi/filterconfig.go index 73d16ec9b1..e7a3aa7ad1 100644 --- a/internal/filterapi/filterconfig.go +++ b/internal/filterapi/filterconfig.go @@ -226,6 +226,8 @@ type BackendAuth struct { AzureAuth *AzureAuth `json:"azure,omitempty"` // GCPAuth specifies the location of GCP credential file. GCPAuth *GCPAuth `json:"gcp,omitempty"` + // OpenAIAuth specifies the OpenAI access token obtained via token exchange. + OpenAIAuth *OpenAIAuth `json:"openai,omitempty"` // CredentialOverride, when non-nil, sources the credential per-request instead of the // static credential above. nil disables per-request sourcing (the default). CredentialOverride *CredentialOverride `json:"credentialOverride,omitempty"` @@ -315,6 +317,25 @@ func (a AzureAuth) LogValue() slog.Value { return slog.GroupValue(slog.String("accessToken", "[REDACTED]")) } +// OpenAIAuth defines the OpenAI access token and the optional organization and project headers. +type OpenAIAuth struct { + // AccessToken is the access token as a literal string. + AccessToken string `json:"accessToken"` + // Organization is sent in the OpenAI-Organization header when set. + Organization string `json:"organization,omitempty"` + // Project is sent in the OpenAI-Project header when set. + Project string `json:"project,omitempty"` +} + +// LogValue implements slog.LogValuer for OpenAIAuth to redact sensitive information. +func (a OpenAIAuth) LogValue() slog.Value { + return slog.GroupValue( + slog.String("accessToken", "[REDACTED]"), + slog.String("organization", a.Organization), + slog.String("project", a.Project), + ) +} + // GCPAuth defines the GCP authentication configuration used to access Google Cloud AI services. type GCPAuth struct { // AccessToken is the access token as a literal string. diff --git a/internal/filterapi/filterconfig_test.go b/internal/filterapi/filterconfig_test.go index d60dd57974..e2627b9b2f 100644 --- a/internal/filterapi/filterconfig_test.go +++ b/internal/filterapi/filterconfig_test.go @@ -126,6 +126,14 @@ func TestAzureAuthLogValue(t *testing.T) { require.Equal(t, "[REDACTED]", attrs["accessToken"]) } +func TestOpenAIAuthLogValue(t *testing.T) { + a := filterapi.OpenAIAuth{AccessToken: "my-access-token", Organization: "org-123", Project: "proj_456"} + attrs := logAttrs(a.LogValue()) + require.Equal(t, "[REDACTED]", attrs["accessToken"]) + require.Equal(t, "org-123", attrs["organization"]) + require.Equal(t, "proj_456", attrs["project"]) +} + func TestGCPAuthLogValue(t *testing.T) { g := filterapi.GCPAuth{AccessToken: "gcp-token", Region: "us-central1", ProjectName: "my-project"} attrs := logAttrs(g.LogValue()) diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml index d4d399754c..360b89fe7c 100644 --- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml @@ -10733,6 +10733,7 @@ spec: AzureAPIKey → x-aigw-azure-api-key AzureCredentials → x-aigw-azure-access-token GCPCredentials → x-aigw-gcp-access-token + OpenAICredentials → x-aigw-openai-access-token For AWSCredentials this is a prefix, not a single header name, since SigV4 needs three inputs. Defaults to "x-aigw-aws-", yielding: @@ -12824,6 +12825,2082 @@ spec: - message: At most one of credentialsFile or workloadIdentityFederationConfig may be specified rule: '!(has(self.credentialsFile) && has(self.workloadIdentityFederationConfig))' + openAICredentials: + description: |- + OpenAICredentials is a mechanism to access OpenAI backend(s) without a static API key. The controller + exchanges a subject token for a short-lived access token via OpenAI Workload Identity Federation, which is + injected into the Authorization header. + properties: + organization: + description: Organization is the OpenAI organization ID. When + set, it is sent in the "OpenAI-Organization" header. + minLength: 1 + type: string + project: + description: Project is the OpenAI project ID. When set, it is + sent in the "OpenAI-Project" header. + minLength: 1 + type: string + workloadIdentityFederationConfig: + description: WorkloadIdentityFederationConfig is the configuration + for OpenAI Workload Identity Federation. + properties: + identityProviderID: + description: |- + IdentityProviderID is the ID of the OpenAI Workload Identity Provider configured for the issuer of the + subject token, e.g. "idp_abc123". + minLength: 1 + type: string + serviceAccountID: + description: |- + ServiceAccountID is the ID of the OpenAI service account the access token is issued for. It must match a + service account mapping of the identity provider. + minLength: 1 + type: string + subjectToken: + description: SubjectToken specifies where the subject token + is obtained from. The subject token must be a JWT. + properties: + oidcExchangeToken: + description: |- + OIDCExchangeToken obtains the subject token from an OIDC provider via the client-credentials flow. + The OIDC provider must return a JWT access token. + properties: + aud: + description: Aud defines the audience that this ID + Token is intended for. + type: string + grantType: + description: GrantType is the method application gets + access token. + type: string + oidc: + description: OIDC is used to obtain oidc tokens via + an SSO server which will be used to exchange for + provider credentials. + properties: + clientID: + description: |- + The client ID to be used in the OIDC + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + + Only one of clientID or clientIDRef must be set. + minLength: 1 + type: string + clientIDRef: + description: |- + The Kubernetes secret which contains the client ID to be used in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + Exactly one of clientID or clientIDRef must be set. + This is an Opaque secret. The client ID should be stored in the key "client-id". + + Only one of clientID or clientIDRef must be set. + properties: + group: + default: "" + description: |- + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Secret + description: Kind is kind of the referent. + For example "Secret". + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referenced object. When unspecified, the local + namespace is inferred. + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - name + type: object + clientSecret: + description: |- + The Kubernetes secret which contains the OIDC client secret to be used in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + + This is an Opaque secret. The client secret should be stored in the key + "client-secret". + properties: + group: + default: "" + description: |- + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Secret + description: Kind is kind of the referent. + For example "Secret". + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referenced object. When unspecified, the local + namespace is inferred. + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - name + type: object + cookieConfig: + description: |- + CookieConfigs allows setting the SameSite attribute for OIDC cookies. + By default, its unset. + properties: + sameSite: + enum: + - Lax + - Strict + - None + type: string + type: object + cookieDomain: + description: |- + The optional domain to set the access and ID token cookies on. + If not set, the cookies will default to the host of the request, not including the subdomains. + If set, the cookies will be set on the specified domain and all subdomains. + This means that requests to any subdomain will not require reauthentication after users log in to the parent domain. + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + cookieNames: + description: |- + The optional cookie name overrides to be used for Bearer and IdToken cookies in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + If not specified, uses a randomly generated suffix + properties: + accessToken: + description: |- + The name of the cookie used to store the AccessToken in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + If not specified, defaults to "AccessToken-(randomly generated uid)" + type: string + idToken: + description: |- + The name of the cookie used to store the IdToken in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + If not specified, defaults to "IdToken-(randomly generated uid)" + type: string + type: object + csrfTokenTTL: + description: |- + CSRFTokenTTL defines how long the CSRF token generated during the OAuth2 authorization flow remains valid. + + This duration determines the lifetime of the CSRF cookie, which is validated against the CSRF token + in the "state" parameter when the provider redirects back to the callback endpoint. + + If omitted, Envoy Gateway defaults the token expiration to 10 minutes. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + defaultRefreshTokenTTL: + description: |- + DefaultRefreshTokenTTL is the default lifetime of the refresh token. + This field is only used when the exp (expiration time) claim is omitted in + the refresh token or the refresh token is not JWT. + + If not specified, defaults to 604800s (one week). + Note: this field is only applicable when the "refreshToken" field is set to true. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + defaultTokenTTL: + description: |- + DefaultTokenTTL is the default lifetime of the id token and access token. + Please note that Envoy will always use the expiry time from the response + of the authorization server if it is provided. This field is only used when + the expiry time is not provided by the authorization. + + If not specified, defaults to 0. In this case, the "expires_in" field in + the authorization response must be set by the authorization server, or the + OAuth flow will fail. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + denyRedirect: + description: |- + Any request that matches any of the provided matchers (with either tokens that are expired or missing tokens) will not be redirected to the OIDC Provider. + This behavior can be useful for AJAX or machine requests. + properties: + headers: + description: Defines the headers to match + against the request to deny redirect to + the OIDC Provider. + items: + description: OIDCDenyRedirectHeader defines + how a header is matched + properties: + name: + description: Specifies the name of the + header in the request. + minLength: 1 + type: string + type: + default: Exact + description: Type specifies how to match + against a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string + value that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array + required: + - headers + type: object + disableTokenEncryption: + description: |- + Disable token encryption. When set to true, both the access token and the ID token will be stored in plain text. + This option should only be used in secure environments where token encryption is not required. + Default is false (tokens are encrypted). + type: boolean + forwardAccessToken: + description: |- + ForwardAccessToken indicates whether the Envoy should forward the access token + via the Authorization header Bearer scheme to the upstream. + If not specified, defaults to false. + type: boolean + forwardIDToken: + description: |- + ForwardIDToken configures forwarding of the OIDC ID token to the upstream. + + If the configured header is "Authorization", EG forwards the ID token using + the "Bearer " prefix. For any other header, EG forwards the raw token value. + If not specified, the ID token will not be forwarded. + + Note: when passThroughAuthHeader is enabled, this header must not be the same + as a header a JWT provider extracts from (the "Authorization" header by + default). The forwarded ID token header is owned by Envoy, and Envoy rejects + an OAuth2 configuration whose pass-through matcher keys on it. + properties: + header: + description: |- + Header is the upstream request header that will carry the ID token. + It must be a valid HTTP header name. Pseudo-headers (names starting with ":") + and the "Host" header are not allowed. + maxLength: 256 + minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ + type: string + x-kubernetes-validations: + - message: header cannot be the Host header + rule: self.lowerAscii() != 'host' + required: + - header + type: object + logoutPath: + description: |- + The path to log a user out, clearing their credential cookies. + + If not specified, uses a default logout path "/logout" + type: string + passThroughAuthHeader: + description: |- + Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless + explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header. + + The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC + redirects and wish to directly supply a token instead. + + If not specified, defaults to false. + type: boolean + provider: + description: The OIDC Provider configuration. + properties: + authorizationEndpoint: + description: |- + The OIDC Provider's [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). + If not provided, EG will try to discover it from the provider's [Well-Known Configuration Endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse). + type: string + backendRef: + description: |- + BackendRef references a Kubernetes object that represents the + backend server to which the authorization request will be sent. + + Deprecated: Use BackendRefs instead. + properties: + group: + default: "" + description: |- + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Service + description: |- + Kind is the Kubernetes resource kind of the referent. For example + "Service". + + Defaults to "Service" when not specified. + + ExternalName services can refer to CNAME DNS records that may live + outside of the cluster and as such are difficult to reason about in + terms of conformance. They also may not be safe to forward to (see + CVE-2021-25740 for more information). Implementations SHOULD NOT + support ExternalName Services. + + Support: Core (Services with a type other than ExternalName) + + Support: Implementation-specific (Services with type ExternalName) + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the backend. When unspecified, the local + namespace is inferred. + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port specifies the destination port number to use for this resource. + Port is required when the referent is a Kubernetes Service. In this + case, the port number is the service port number, not the target port. + For other resources, destination port might be derived from the referent + resource or this field. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - name + type: object + x-kubernetes-validations: + - message: Must have port for Service reference + rule: '(size(self.group) == 0 && self.kind + == ''Service'') ? has(self.port) : true' + backendRefs: + description: |- + BackendRefs references a Kubernetes object that represents the + backend server to which the authorization request will be sent. + items: + description: BackendRef defines how an ObjectReference + that is specific to BackendRef. + properties: + fallback: + description: |- + Fallback indicates whether the backend is designated as a fallback. + Multiple fallback backends can be configured. + It is highly recommended to configure active or passive health checks to ensure that failover can be detected + when the active backends become unhealthy and to automatically readjust once the primary backends are healthy again. + The overprovisioning factor is set to 1.4, meaning the fallback backends will only start receiving traffic when + the health of the active backends falls below 72%. + type: boolean + group: + default: "" + description: |- + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Service + description: |- + Kind is the Kubernetes resource kind of the referent. For example + "Service". + + Defaults to "Service" when not specified. + + ExternalName services can refer to CNAME DNS records that may live + outside of the cluster and as such are difficult to reason about in + terms of conformance. They also may not be safe to forward to (see + CVE-2021-25740 for more information). Implementations SHOULD NOT + support ExternalName Services. + + Support: Core (Services with a type other than ExternalName) + + Support: Implementation-specific (Services with type ExternalName) + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the + referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the backend. When unspecified, the local + namespace is inferred. + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port specifies the destination port number to use for this resource. + Port is required when the referent is a Kubernetes Service. In this + case, the port number is the service port number, not the target port. + For other resources, destination port might be derived from the referent + resource or this field. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + weight: + default: 1 + description: |- + Weight specifies the proportion of requests forwarded to the referenced + backend. This is computed as weight/(sum of all weights in this + BackendRefs list). For non-zero values, there may be some epsilon from + the exact proportion defined here depending on the precision an + implementation supports. Weight is not a percentage and the sum of + weights does not need to equal 100. + + If only one backend is specified and it has a weight greater than 0, 100% + of the traffic is forwarded to that backend. If weight is set to 0, no + traffic should be forwarded for this entry. If unspecified, weight + defaults to 1. + + Support for this field varies based on the context where used. + format: int32 + maximum: 1000000 + minimum: 0 + type: integer + required: + - name + type: object + x-kubernetes-validations: + - message: Must have port for Service reference + rule: '(size(self.group) == 0 && self.kind + == ''Service'') ? has(self.port) : true' + maxItems: 16 + type: array + backendSettings: + description: |- + BackendSettings holds configuration for managing the connection + to the backend. + properties: + circuitBreaker: + description: |- + Circuit Breaker settings for the upstream connections and requests. + If not set, circuit breakers will be enabled with the default thresholds + properties: + maxConnections: + default: 1024 + description: The maximum number of + connections that Envoy will establish + to the referenced backend defined + within a xRoute rule. + format: int64 + maximum: 4294967295 + minimum: 0 + type: integer + maxParallelRequests: + default: 1024 + description: The maximum number of + parallel requests that Envoy will + make to the referenced backend defined + within a xRoute rule. + format: int64 + maximum: 4294967295 + minimum: 0 + type: integer + maxParallelRetries: + default: 1024 + description: The maximum number of + parallel retries that Envoy will + make to the referenced backend defined + within a xRoute rule. + format: int64 + maximum: 4294967295 + minimum: 0 + type: integer + maxPendingRequests: + default: 1024 + description: The maximum number of + pending requests that Envoy will + queue to the referenced backend + defined within a xRoute rule. + format: int64 + maximum: 4294967295 + minimum: 0 + type: integer + maxRequestsPerConnection: + description: |- + The maximum number of requests that Envoy will make over a single connection to the referenced backend defined within a xRoute rule. + Default: unlimited. + format: int64 + maximum: 4294967295 + minimum: 0 + type: integer + perEndpoint: + description: PerEndpoint defines Circuit + Breakers that will apply per-endpoint + for an upstream cluster + properties: + maxConnections: + default: 1024 + description: MaxConnections configures + the maximum number of connections + that Envoy will establish per-endpoint + to the referenced backend defined + within a xRoute rule. + format: int64 + maximum: 4294967295 + minimum: 0 + type: integer + type: object + retryBudget: + description: |- + RetryBudget provides settings for retry budget, which limits the number of retries in a given percentage. + RetryBudget take precedence over maxParallelRetries. + properties: + minRetryConcurrency: + description: |- + MinRetryConcurrency specifies the minimum retry concurrency allowed for the retry budget. + For example, a budget of 20% with a minimum retry concurrency of 3 + will allow 5 active retries while there are 25 active requests. + If there are 2 active requests, there are still 3 active retries + allowed because of the minimum retry concurrency. + Defaults to 3. + format: int32 + type: integer + percent: + description: |- + Percent specifies the limit on concurrent retries as a percentage [0, 100] of + the sum of active requests and active pending requests. + properties: + denominator: + default: 100 + format: int32 + minimum: 1 + type: integer + numerator: + format: int32 + minimum: 0 + type: integer + required: + - numerator + type: object + x-kubernetes-validations: + - message: numerator must be less + than or equal to denominator + rule: self.numerator <= self.denominator + required: + - percent + type: object + type: object + connection: + description: Connection includes backend + connection settings. + properties: + bufferLimit: + allOf: + - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + - pattern: ^[1-9]+[0-9]*([EPTGMK]i|[EPTGMk])?$ + anyOf: + - type: integer + - type: string + description: |- + BufferLimit Soft limit on size of the cluster’s connections read and write buffers. + BufferLimit applies to connection streaming (maybe non-streaming) channel between processes, it's in user space. + If unspecified, an implementation defined default is applied (32768 bytes). + For example, 20Mi, 1Gi, 256Ki etc. + Note: that when the suffix is not provided, the value is interpreted as bytes. + x-kubernetes-int-or-string: true + preconnect: + description: |- + Preconnect configures proactive upstream connections to reduce latency by establishing + connections before they’re needed and avoiding connection establishment overhead. + + If unset, Envoy will fetch connections as needed to serve in-flight requests. + properties: + perEndpointPercent: + description: |- + PerEndpointPercent configures how many additional connections to maintain per + upstream endpoint, useful for high-QPS or latency sensitive services. Expressed as a + percentage of the connections required by active streams + (e.g. 100 = preconnect disabled, 105 = 1.05x connections per-endpoint, 200 = 2.00×). + + Allowed value range is between 100-300. When both PerEndpointPercent and + PredictivePercent are set, Envoy ensures both are satisfied (max of the two). + format: int32 + maximum: 300 + minimum: 100 + type: integer + predictivePercent: + description: |- + PredictivePercent configures how many additional connections to maintain + across the cluster by anticipating which upstream endpoint the load balancer + will select next, useful for low-QPS services. Relies on deterministic + loadbalancing and is only supported with Random or RoundRobin. + Expressed as a percentage of the connections required by active streams + (e.g. 100 = 1.0 (no preconnect), 105 = 1.05× connections across the cluster, 200 = 2.00×). + + Minimum allowed value is 100. When both PerEndpointPercent and PredictivePercent are + set Envoy ensures both are satisfied per host (max of the two). + format: int32 + minimum: 100 + type: integer + type: object + socketBufferLimit: + allOf: + - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + - pattern: ^[1-9]+[0-9]*([EPTGMK]i|[EPTGMk])?$ + anyOf: + - type: integer + - type: string + description: |- + SocketBufferLimit provides configuration for the maximum buffer size in bytes for each socket + to backend. + SocketBufferLimit applies to socket streaming channel between TCP/IP stacks, it's in kernel space. + For example, 20Mi, 1Gi, 256Ki etc. + Note that when the suffix is not provided, the value is interpreted as bytes. + x-kubernetes-int-or-string: true + type: object + dns: + description: DNS includes dns resolution + settings. + properties: + dnsRefreshRate: + description: |- + DNSRefreshRate specifies the rate at which DNS records should be refreshed. + Defaults to 30 seconds. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + lookupFamily: + description: |- + LookupFamily determines how Envoy would resolve DNS for Routes where the backend is specified as a fully qualified domain name (FQDN). + If set, this configuration overrides other defaults. + enum: + - IPv4 + - IPv6 + - IPv4Preferred + - IPv6Preferred + - IPv4AndIPv6 + type: string + respectDnsTtl: + description: |- + RespectDNSTTL indicates whether the DNS Time-To-Live (TTL) should be respected. + If the value is set to true, the DNS refresh rate will be set to the resource record’s TTL. + Defaults to true. + type: boolean + type: object + healthCheck: + description: HealthCheck allows gateway + to perform active health checking on + backends. + properties: + active: + description: Active health check configuration + properties: + grpc: + description: |- + GRPC defines the configuration of the GRPC health checker. + It's optional, and can only be used if the specified type is GRPC. + properties: + service: + description: |- + Service to send in the health check request. + If this is not specified, then the health check request applies to the entire + server and not to a specific service. + type: string + type: object + healthCheckLog: + description: |- + HealthCheckLog defines health check event logging configuration for this cluster. + When set, HC probe outcomes are logged to the configured sinks. + Takes precedence over the gateway-level EnvoyProxy.spec.telemetry.healthCheckLog. + properties: + matches: + description: |- + Matches defines which health check probe outcomes produce a log entry. + When omitted or empty, all events are logged. + + Each value must be unique. Multiple values are ORed. If any failure type is + specified then a success type must also be specified, and vice versa. + items: + description: ProxyHealthCheckLogEventType + specifies which health + check probe outcomes produce + a log entry. + enum: + - Failure + - FailureSeriesStart + - Success + - HealthyTransition + type: string + maxItems: 4 + type: array + x-kubernetes-list-type: set + x-kubernetes-validations: + - message: a failure type + and a success type must + both be specified together + rule: self.exists(e, e == + 'Failure' || e == 'FailureSeriesStart') + == self.exists(e, e == + 'Success' || e == 'HealthyTransition') + sinks: + description: |- + Sinks defines where health check events are written. + When omitted, events are written to /dev/stdout. + items: + description: ProxyHealthCheckLogSink + defines a destination + for health check event + logs. + properties: + file: + description: |- + File defines the file sink configuration. + Required when type is File. + properties: + path: + description: |- + Path specifies the file path for health check event output. + Use /dev/stdout to write to standard output. + minLength: 1 + type: string + required: + - path + type: object + type: + description: Type defines + the type of sink. + enum: + - File + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: If ProxyHealthCheckLogSink + type is File, file field + needs to be set. + rule: 'self.type == ''File'' + ? has(self.file) : !has(self.file)' + maxItems: 1 + type: array + type: object + healthyThreshold: + default: 1 + description: HealthyThreshold + defines the number of healthy + health checks required before + a backend host is marked healthy. + format: int32 + minimum: 1 + type: integer + http: + description: |- + HTTP defines the configuration of http health checker. + It's required while the health checker type is HTTP. + properties: + expectedResponse: + description: ExpectedResponse + defines a list of HTTP expected + responses to match. + properties: + binary: + description: Binary payload + base64 encoded. + format: byte + type: string + text: + description: Text payload + in plain text. + type: string + type: + allOf: + - enum: + - Text + - Binary + - enum: + - Text + - Binary + description: Type defines + the type of the payload. + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: If payload type + is Text, text field needs + to be set. + rule: 'self.type == ''Text'' + ? has(self.text) : !has(self.text)' + - message: If payload type + is Binary, binary field + needs to be set. + rule: 'self.type == ''Binary'' + ? has(self.binary) : !has(self.binary)' + expectedStatuses: + description: |- + ExpectedStatuses defines a list of HTTP response statuses considered healthy. + Defaults to 200 only + items: + description: HTTPStatus + defines the http status + code. + maximum: 599 + minimum: 100 + type: integer + type: array + hostname: + description: |- + Hostname defines the HTTP Host header used for active HTTP health checks. + Host selection uses this order: this field, the associated Backend endpoint + hostname if available, then the effective Route hostname. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + method: + description: |- + Method defines the HTTP method used for health checking. + Defaults to GET + maxLength: 16 + type: string + path: + description: Path defines + the HTTP path that will + be requested during health + checking. + maxLength: 1024 + minLength: 1 + type: string + requestBody: + description: RequestBody defines + the HTTP request body payload + sent during health checking. + properties: + binary: + description: Binary payload + base64 encoded. + format: byte + type: string + text: + description: Text payload + in plain text. + type: string + type: + allOf: + - enum: + - Text + - Binary + - enum: + - Text + - Binary + description: Type defines + the type of the payload. + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: If payload type + is Text, text field needs + to be set. + rule: 'self.type == ''Text'' + ? has(self.text) : !has(self.text)' + - message: If payload type + is Binary, binary field + needs to be set. + rule: 'self.type == ''Binary'' + ? has(self.binary) : !has(self.binary)' + retriableStatuses: + description: |- + RetriableStatuses defines a list of HTTP response statuses considered retriable. + Responses matching these statuses count towards the unhealthy threshold but + do not result in the host being considered immediately unhealthy. + The expected statuses take precedence for any range overlaps with this field. + items: + description: HTTPStatus + defines the http status + code. + maximum: 599 + minimum: 100 + type: integer + type: array + required: + - path + type: object + x-kubernetes-validations: + - message: The requestBody field + can only be set when method + is POST or PUT. + rule: '!has(self.requestBody) + || (has(self.method) && self.method.upperAscii() + in [''POST'',''PUT''])' + initialJitter: + description: |- + InitialJitter defines the maximum time Envoy will wait before the first health check. + Envoy will randomly select a value between 0 and the initial jitter value. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + interval: + default: 3s + description: Interval defines + the time between active health + checks. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + overrides: + description: |- + Overrides defines the configuration of the overriding health check settings for all endpoints + in the backend cluster. This allows customization of port and other settings that may differ + from the main service configuration. + properties: + port: + description: |- + Port overrides the health check port. + If not set, the endpoint's serving port is used for health checks. + This is useful when health checks are served on a different port than + the main service port (e.g., port 443 for service, port 9090 for health checks). + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + tcp: + description: |- + TCP defines the configuration of tcp health checker. + It's required while the health checker type is TCP. + properties: + receive: + description: Receive defines + the expected response payload. + properties: + binary: + description: Binary payload + base64 encoded. + format: byte + type: string + text: + description: Text payload + in plain text. + type: string + type: + allOf: + - enum: + - Text + - Binary + - enum: + - Text + - Binary + description: Type defines + the type of the payload. + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: If payload type + is Text, text field needs + to be set. + rule: 'self.type == ''Text'' + ? has(self.text) : !has(self.text)' + - message: If payload type + is Binary, binary field + needs to be set. + rule: 'self.type == ''Binary'' + ? has(self.binary) : !has(self.binary)' + send: + description: Send defines + the request payload. + properties: + binary: + description: Binary payload + base64 encoded. + format: byte + type: string + text: + description: Text payload + in plain text. + type: string + type: + allOf: + - enum: + - Text + - Binary + - enum: + - Text + - Binary + description: Type defines + the type of the payload. + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: If payload type + is Text, text field needs + to be set. + rule: 'self.type == ''Text'' + ? has(self.text) : !has(self.text)' + - message: If payload type + is Binary, binary field + needs to be set. + rule: 'self.type == ''Binary'' + ? has(self.binary) : !has(self.binary)' + type: object + timeout: + default: 1s + description: Timeout defines the + time to wait for a health check + response. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: + allOf: + - enum: + - HTTP + - TCP + - GRPC + - enum: + - HTTP + - TCP + - GRPC + description: Type defines the + type of health checker. + type: string + unhealthyThreshold: + default: 3 + description: |- + UnhealthyThreshold defines the number of unhealthy health checks required before a backend host is marked unhealthy. + Without RetriableStatuses configured, any health check failure results in the host being immediately + considered unhealthy. When RetriableStatuses is set, health checks returning those statuses are retried + up to this threshold before the host is marked unhealthy. + format: int32 + minimum: 1 + type: integer + required: + - type + type: object + x-kubernetes-validations: + - message: If Health Checker type + is HTTP, http field needs to be + set. + rule: 'self.type == ''HTTP'' ? has(self.http) + : !has(self.http)' + - message: If Health Checker type + is TCP, tcp field needs to be + set. + rule: 'self.type == ''TCP'' ? has(self.tcp) + : !has(self.tcp)' + - message: The grpc field can only + be set if the Health Checker type + is GRPC. + rule: 'has(self.grpc) ? self.type + == ''GRPC'' : true' + panicThreshold: + description: |- + When number of unhealthy endpoints for a backend reaches this threshold + Envoy will disregard health status and balance across all endpoints. + It's designed to prevent a situation in which host failures cascade throughout the cluster + as load increases. If not set, the default value is 50%. To disable panic mode, set value to `0`. + format: int32 + maximum: 100 + minimum: 0 + type: integer + passive: + description: Passive passive check + configuration + properties: + alwaysEjectOneEndpoint: + default: false + description: |- + AlwaysEjectOneEndpoint defines whether at least one host should be ejected, + regardless of MaxEjectionPercent. + type: boolean + baseEjectionTime: + default: 30s + description: BaseEjectionTime + defines the base duration for + which a host will be ejected + on consecutive failures. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + consecutive5XxErrors: + default: 5 + description: Consecutive5xxErrors + sets the number of consecutive + 5xx errors triggering ejection. + format: int32 + type: integer + consecutiveGatewayErrors: + description: ConsecutiveGatewayErrors + sets the number of consecutive + gateway errors triggering ejection. + format: int32 + type: integer + consecutiveLocalOriginFailures: + default: 5 + description: |- + ConsecutiveLocalOriginFailures sets the number of consecutive local origin failures triggering ejection. + Parameter takes effect only when split_external_local_origin_errors is set to true. + format: int32 + type: integer + failurePercentageThreshold: + description: |- + FailurePercentageThreshold sets the failure percentage threshold for outlier detection. + If the failure percentage of a given host is greater than or equal to this value, it will be ejected. + Defaults to 85. + format: int32 + maximum: 100 + minimum: 0 + type: integer + interval: + default: 3s + description: Interval defines + the time between passive health + checks. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + maxEjectionPercent: + default: 10 + description: MaxEjectionPercent + sets the maximum percentage + of hosts in a cluster that can + be ejected. + format: int32 + maximum: 100 + minimum: 0 + type: integer + splitExternalLocalOriginErrors: + default: false + description: SplitExternalLocalOriginErrors + enables splitting of errors + between external and local origin. + type: boolean + type: object + type: object + http2: + description: HTTP2 provides HTTP/2 configuration + for backend connections. + properties: + connectionKeepalive: + description: ConnectionKeepalive configures + HTTP/2 connection keepalive using + PING frames. + properties: + idleInterval: + description: IdleInterval specifies + how long a connection must be + idle before a PING is sent. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + interval: + description: Interval specifies + how often to send HTTP/2 PING + frames to keep the connection + alive. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + intervalJitter: + description: |- + IntervalJitter specifies a random jitter percentage added to each interval. + Defaults to 15% if not specified. + format: int32 + maximum: 100 + minimum: 0 + type: integer + timeout: + description: Timeout specifies + how long to wait for a PING + response before considering + the connection dead. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: object + x-kubernetes-validations: + - message: timeout must be less than + interval + rule: '!has(self.timeout) || !has(self.interval) + || duration(self.timeout) < duration(self.interval)' + initialConnectionWindowSize: + allOf: + - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + - pattern: ^[1-9]+[0-9]*([EPTGMK]i|[EPTGMk])?$ + anyOf: + - type: integer + - type: string + description: |- + InitialConnectionWindowSize sets the initial window size for HTTP/2 connections. + If not set, the default value is 1 MiB. + x-kubernetes-int-or-string: true + initialStreamWindowSize: + allOf: + - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + - pattern: ^[1-9]+[0-9]*([EPTGMK]i|[EPTGMk])?$ + anyOf: + - type: integer + - type: string + description: |- + InitialStreamWindowSize sets the initial window size for HTTP/2 streams. + If not set, the default value is 64 KiB(64*1024). + x-kubernetes-int-or-string: true + maxConcurrentStreams: + description: |- + MaxConcurrentStreams sets the maximum number of concurrent streams allowed per connection. + If not set, the default value is 100. + format: int32 + maximum: 2147483647 + minimum: 1 + type: integer + onInvalidMessage: + description: |- + OnInvalidMessage determines if Envoy will terminate the connection or just the offending stream in the event of HTTP messaging error + It's recommended for L2 Envoy deployments to set this value to TerminateStream. + https://www.envoyproxy.io/docs/envoy/latest/configuration/best_practices/level_two + Default: TerminateConnection + type: string + type: object + loadBalancer: + description: |- + LoadBalancer policy to apply when routing traffic from the gateway to + the backend endpoints. Defaults to `LeastRequest`. + properties: + backendUtilization: + description: |- + BackendUtilization defines the configuration when the load balancer type is + set to BackendUtilization. + properties: + blackoutPeriod: + description: |- + A given endpoint must report load metrics continuously for at least this long before the endpoint weight will be used. + Default is 10s. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + errorUtilizationPenaltyPercent: + description: |- + ErrorUtilizationPenaltyPercent adjusts endpoint weights based on the error rate (eps/qps). + This is expressed as a percentage-based integer where 100 represents 1.0, 150 represents 1.5, etc. + + For example: + - 100 => 1.0x + - 120 => 1.2x + - 200 => 2.0x + + Must be non-negative. + format: int32 + minimum: 0 + type: integer + keepResponseHeaders: + default: false + description: |- + KeepResponseHeaders keeps the ORCA load report headers/trailers before sending the response to the client. + Defaults to false. + type: boolean + metricNamesForComputingUtilization: + description: |- + Metric names used to compute utilization if application_utilization is not set. + For map fields in ORCA proto, use the form ".", e.g., "named_metrics.foo". + items: + type: string + type: array + outOfBand: + description: |- + OutOfBand enables out-of-band ORCA load reporting. When set, Envoy opens a + server-streaming gRPC connection to each endpoint's + xds.service.orca.v3.OpenRcaService/StreamCoreMetrics and pulls load + reports periodically, instead of relying on in-band ORCA metrics + carried in response headers/trailers. + + The backend must implement OpenRcaService for this to take effect. + properties: + authority: + description: |- + Authority overrides the :authority header on the OutOfBand gRPC stream. + If unset, Envoy uses the endpoint hostname, then the dialed address, then + the cluster name. + maxLength: 259 + minLength: 1 + pattern: ^[^\x00\n\r]*$ + type: string + port: + description: |- + Port overrides the port used for the OutOfBand reporting connection, e.g. to + reach a separate reporting sidecar. Defaults to the endpoint's port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + reportingPeriod: + description: |- + ReportingPeriod is how often Envoy requests load reports from the server. + Must be greater than 0. Defaults to 10s. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + x-kubernetes-validations: + - message: reportingPeriod + must be greater than 0 + rule: duration(self) > duration('0s') + type: object + weightExpirationPeriod: + description: If a given endpoint + has not reported load metrics + in this long, stop using the + reported weight. Defaults to + 3m. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + weightUpdatePeriod: + description: How often endpoint + weights are recalculated. Values + less than 100ms are capped at + 100ms. Default 1s. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: object + consistentHash: + description: |- + ConsistentHash defines the configuration when the load balancer type is + set to ConsistentHash + properties: + cookie: + description: Cookie configures + the cookie hash policy when + the consistent hash type is + set to Cookie. + properties: + attributes: + additionalProperties: + type: string + description: Additional Attributes + to set for the generated + cookie. + type: object + name: + description: |- + Name of the cookie to hash. + If this cookie does not exist in the request, Envoy will generate a cookie and set + the TTL on the response back to the client based on Layer 4 + attributes of the backend endpoint, to ensure that these future requests + go to the same backend endpoint. Make sure to set the TTL field for this case. + type: string + ttl: + description: |- + TTL of the generated cookie if the cookie is not present. This value sets the + Max-Age attribute value. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + required: + - name + type: object + header: + description: |- + Header configures the header hash policy when the consistent hash type is set to Header. + + Deprecated: use Headers instead + properties: + name: + description: Name of the header + to hash. + type: string + required: + - name + type: object + headers: + description: Headers configures + the header hash policy for each + header, when the consistent + hash type is set to Headers. + items: + description: |- + Header defines the header hashing configuration for consistent hash based + load balancing. + properties: + name: + description: Name of the + header to hash. + type: string + required: + - name + type: object + type: array + queryParams: + description: QueryParams configures + the query parameter hash policy + when the consistent hash type + is set to QueryParams. + items: + description: |- + QueryParam defines the query parameter name hashing configuration for consistent hash based + load balancing. + properties: + name: + description: Name of the + query param to hash. + type: string + required: + - name + type: object + type: array + tableSize: + default: 65537 + description: The table size for + consistent hashing, must be + prime number limited to 5000011. + format: int64 + maximum: 5000011 + minimum: 2 + type: integer + type: + description: |- + ConsistentHashType defines the type of input to hash on. Valid Type values are + "SourceIP", + "Header", + "Headers", + "Cookie". + "QueryParams". + enum: + - SourceIP + - Header + - Headers + - Cookie + - QueryParams + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: If consistent hash type + is header, the header field must + be set. + rule: 'self.type == ''Header'' ? + has(self.header) : !has(self.header)' + - message: If consistent hash type + is headers, the headers field + must be set. + rule: 'self.type == ''Headers'' + ? has(self.headers) : !has(self.headers)' + - message: If consistent hash type + is cookie, the cookie field must + be set. + rule: 'self.type == ''Cookie'' ? + has(self.cookie) : !has(self.cookie)' + - message: If consistent hash type + is queryParams, the queryParams + field must be set. + rule: 'self.type == ''QueryParams'' + ? has(self.queryParams) : !has(self.queryParams)' + dynamicModule: + description: |- + DynamicModule defines the configuration when the load balancer type is + set to DynamicModule. The referenced module must be registered in the + EnvoyProxy resource's dynamicModules allowlist. + properties: + config: + description: |- + Config is optional configuration for the module's load balancer + implementation. This is serialized and passed to the module's + initialization function. + x-kubernetes-preserve-unknown-fields: true + lbPolicyName: + description: |- + LBPolicyName identifies a specific load balancer implementation within + the dynamic module. A single shared library can contain multiple LB + policy implementations. This value is passed to the module's + initialization function to select the appropriate implementation. + maxLength: 253 + minLength: 1 + type: string + name: + description: |- + Name references a dynamic module registered in the EnvoyProxy resource's + dynamicModules list. The referenced module must exist in the registry; + otherwise, the policy will be rejected. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + required: + - lbPolicyName + - name + type: object + endpointOverride: + description: |- + EndpointOverride defines the configuration for endpoint override. + When specified, the load balancer will attempt to route requests to endpoints + based on the override information extracted from request headers or metadata. + If the override endpoints are not available, the configured load balancer policy will be used as fallback. + properties: + extractFrom: + description: ExtractFrom defines + the sources to extract endpoint + override information from. + items: + description: EndpointOverrideExtractFrom + defines a source to extract + endpoint override information + from. + properties: + header: + description: |- + Header defines the header to get the override endpoint addresses. + The header value must specify at least one endpoint in `IP:Port` format or multiple endpoints in `IP:Port,IP:Port,...` format. + For example `10.0.0.5:8080` or `[2600:4040:5204::1574:24ae]:80`. + The IPv6 address is enclosed in square brackets. + type: string + type: object + maxItems: 10 + minItems: 1 + type: array + required: + - extractFrom + type: object + slowStart: + description: |- + SlowStart defines the configuration related to the slow start load balancer policy. + If set, during slow start window, traffic sent to the newly added hosts will gradually increase. + Supported for RoundRobin, LeastRequest, and BackendUtilization load balancers. + properties: + window: + description: |- + Window defines the duration of the warm up period for newly added host. + During slow start window, traffic sent to the newly added hosts will gradually increase. + Currently only supports linear growth of traffic. For additional details, + see https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto#config-cluster-v3-cluster-slowstartconfig + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + required: + - window + type: object + type: + description: |- + Type decides the type of Load Balancer policy. + Valid LoadBalancerType values are + "ConsistentHash", + "LeastRequest", + "Random", + "RoundRobin", + "BackendUtilization", + "DynamicModule". + enum: + - ConsistentHash + - LeastRequest + - Random + - RoundRobin + - BackendUtilization + - DynamicModule + type: string + zoneAware: + description: ZoneAware defines the + configuration related to the distribution + of requests between locality zones. + properties: + preferLocal: + description: PreferLocalZone configures + zone-aware routing to prefer + sending traffic to the local + locality zone. + properties: + force: + description: |- + ForceLocalZone defines override configuration for forcing all traffic to stay within the local zone instead of the default behavior + which maintains equal distribution among upstream endpoints while sending as much traffic as possible locally. + properties: + minEndpointsInZoneThreshold: + description: |- + MinEndpointsInZoneThreshold is the minimum number of upstream endpoints in the local zone required to honor the forceLocalZone + override. This is useful for protecting zones with fewer endpoints. + format: int32 + type: integer + type: object + minEndpointsThreshold: + description: MinEndpointsThreshold + is the minimum number of + total upstream endpoints + across all zones required + to enable zone-aware routing. + format: int64 + type: integer + percentageEnabled: + description: Configures percentage + of requests that will be + considered for zone aware + routing if zone aware routing + is configured. If not specified, + Envoy defaults to 100%. + format: int32 + maximum: 100 + minimum: 0 + type: integer + type: object + weightedZones: + description: |- + WeightedZones configures weight-based traffic distribution across locality zones. + Traffic is distributed proportionally based on the sum of all zone weights. + items: + description: WeightedZoneConfig + defines the weight for a specific + locality zone. + properties: + weight: + description: |- + Weight defines the weight for this locality. + Higher values receive more traffic. The actual traffic distribution + is proportional to this value relative to other localities. + format: int32 + type: integer + zone: + description: |- + Zone specifies the topology zone this weight applies to. + The value should match the topology.kubernetes.io/zone label + of the nodes where endpoints are running. + Zones not listed in the configuration receive a default weight of 1. + type: string + required: + - weight + - zone + type: object + type: array + x-kubernetes-list-map-keys: + - zone + x-kubernetes-list-type: map + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: If LoadBalancer type is consistentHash, + consistentHash field needs to be set. + rule: 'self.type == ''ConsistentHash'' + ? has(self.consistentHash) : !has(self.consistentHash)' + - message: If LoadBalancer type is BackendUtilization, + backendUtilization field needs to + be set. + rule: 'self.type == ''BackendUtilization'' + ? has(self.backendUtilization) : !has(self.backendUtilization)' + - message: If LoadBalancer type is DynamicModule, + dynamicModule field needs to be set. + rule: 'self.type == ''DynamicModule'' + ? has(self.dynamicModule) : !has(self.dynamicModule)' + - message: Currently SlowStart is only + supported for RoundRobin, LeastRequest, + and BackendUtilization load balancers. + rule: 'self.type in [''Random'', ''ConsistentHash'', + ''DynamicModule''] ? !has(self.slowStart) + : true' + - message: PreferLocal zone-aware routing + is not supported for ConsistentHash + load balancers. Use weightedZones + instead. + rule: 'self.type == ''ConsistentHash'' + && has(self.zoneAware) ? !has(self.zoneAware.preferLocal) + : true' + - message: PreferLocal zone-aware routing + is not currently supported for BackendUtilization + load balancers. Only WeightedZones + can be used with BackendUtilization. + rule: 'self.type == ''BackendUtilization'' + && has(self.zoneAware) ? !has(self.zoneAware.preferLocal) + : true' + - message: ZoneAware routing is not supported + for DynamicModule load balancers. + rule: 'self.type == ''DynamicModule'' + ? !has(self.zoneAware) : true' + - message: ZoneAware PreferLocal and WeightedZones + cannot be specified together. + rule: 'has(self.zoneAware) ? !(has(self.zoneAware.preferLocal) + && has(self.zoneAware.weightedZones)) + : true' + - message: EndpointOverride is not supported + for DynamicModule load balancers. + rule: 'self.type == ''DynamicModule'' + ? !has(self.endpointOverride) : true' + proxyProtocol: + description: ProxyProtocol enables the + Proxy Protocol when communicating with + the backend. + properties: + version: + description: |- + Version of ProxyProtocol + Valid ProxyProtocolVersion values are + "V1" + "V2" + enum: + - V1 + - V2 + type: string + required: + - version + type: object + retry: + description: |- + Retry provides more advanced usage, allowing users to customize the number of retries, retry fallback strategy, and retry triggering conditions. + If not set, retry will be disabled. + properties: + numAttemptsPerPriority: + description: |- + NumAttemptsPerPriority defines the number of requests (initial attempt + retries) + that should be sent to the same priority before switching to a different one. + If not specified or set to 0, all requests are sent to the highest priority that is healthy. + format: int32 + type: integer + numRetries: + default: 2 + description: NumRetries is the number + of retries to be attempted. Defaults + to 2. + format: int32 + minimum: 0 + type: integer + perRetry: + description: PerRetry is the retry + policy to be applied per retry attempt. + properties: + backOff: + description: |- + Backoff is the backoff policy to be applied per retry attempt. gateway uses a fully jittered exponential + back-off algorithm for retries. For additional details, + see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/router_filter#config-http-filters-router-x-envoy-max-retries + properties: + baseInterval: + description: BaseInterval + is the base interval between + retries. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + maxInterval: + description: |- + MaxInterval is the maximum interval between retries. This parameter is optional, but must be greater than or equal to the base_interval if set. + The default is 10 times the base_interval + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: object + timeout: + description: Timeout is the timeout + per retry attempt. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: object + retryOn: + description: |- + RetryOn specifies the retry trigger condition. + + If not specified, the default is to retry on connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes(503). + properties: + httpStatusCodes: + description: |- + HttpStatusCodes specifies the http status codes to be retried. + The retriable-status-codes trigger must also be configured for these status codes to trigger a retry. + items: + description: HTTPStatus defines + the http status code. + maximum: 599 + minimum: 100 + type: integer + type: array + triggers: + description: Triggers specifies + the retry trigger condition(Http/Grpc). + items: + description: TriggerEnum specifies + the conditions that trigger + retries. + enum: + - 5xx + - gateway-error + - reset + - reset-before-request + - connect-failure + - retriable-4xx + - refused-stream + - retriable-status-codes + - cancelled + - deadline-exceeded + - internal + - resource-exhausted + - unavailable + type: string + type: array + type: object + type: object + tcpKeepalive: + description: |- + TcpKeepalive settings associated with the upstream client connection. + Disabled by default. + properties: + idleTime: + description: |- + The duration a connection needs to be idle before keep-alive + probes start being sent. + The duration format is + Defaults to `7200s`. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + interval: + description: |- + The duration between keep-alive probes. + Defaults to `75s`. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + probes: + description: |- + The total number of unacknowledged probes to send before deciding + the connection is dead. + Defaults to 9. + format: int32 + type: integer + type: object + timeout: + description: Timeout settings for the + backend connections. + properties: + http: + description: Timeout settings for + HTTP. + properties: + connectionIdleTimeout: + description: |- + The idle timeout for an HTTP connection. Idle time is defined as a period in which there are no active requests in the connection. + Default: 1 hour. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + maxConnectionDuration: + description: |- + The maximum duration of an HTTP connection. + Default: unlimited. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + maxStreamDuration: + description: |- + MaxStreamDuration is the maximum duration for a stream to complete. This timeout measures the time + from when the request is sent until the response stream is fully consumed and does not apply to + non-streaming requests. + When set to "0s", no max duration is applied and streams can run indefinitely. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + requestTimeout: + description: RequestTimeout is + the time until which entire + response is received from the + upstream. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + streamIdleTimeout: + description: |2- + The stream idle timeout defines the amount of time a stream can exist without any upstream or downstream activity. + If not specified, StreamIdleTimeout is inherited from the listener-level setting, which can be configured via ClientTrafficPolicy. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: object + tcp: + description: Timeout settings for + TCP. + properties: + connectTimeout: + description: |- + The timeout for network connection establishment, including TCP and TLS handshakes. + Default: 10 seconds. + pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$ + type: string + type: object + type: object + type: object + x-kubernetes-validations: + - message: predictivePercent in preconnect + policy only works with RoundRobin or Random + load balancers + rule: '!((has(self.connection) && has(self.connection.preconnect) + && has(self.connection.preconnect.predictivePercent)) + && !(has(self.loadBalancer) && has(self.loadBalancer.type) + && self.loadBalancer.type in [''Random'', + ''RoundRobin'']))' + endSessionEndpoint: + description: |- + The OIDC Provider's [end session endpoint](https://openid.net/specs/openid-connect-core-1_0.html#RPLogout). + + If the end session endpoint is provided, EG will use it to log out the user from the OIDC Provider when the user accesses the logout path. + EG will also try to discover the end session endpoint from the provider's [Well-Known Configuration Endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse) when authorizationEndpoint or tokenEndpoint is not provided. + type: string + issuer: + description: |- + The OIDC Provider's [issuer identifier](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery). + Issuer MUST be a URI RFC 3986 [RFC3986] with a scheme component that MUST + be https, a host component, and optionally, port and path components and + no query or fragment components. + pattern: ^https://[^/?#@]+(/[^?#]*)?$ + type: string + tokenEndpoint: + description: |- + The OIDC Provider's [token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). + If not provided, EG will try to discover it from the provider's [Well-Known Configuration Endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse). + type: string + required: + - issuer + type: object + x-kubernetes-validations: + - message: BackendRefs must be used, backendRef + is not supported. + rule: '!has(self.backendRef)' + - message: Retry timeout is not supported. + rule: has(self.backendSettings)? (has(self.backendSettings.retry)?(has(self.backendSettings.retry.perRetry)? + !has(self.backendSettings.retry.perRetry.timeout):true):true):true + - message: HTTPStatusCodes is not supported. + rule: has(self.backendSettings)? (has(self.backendSettings.retry)?(has(self.backendSettings.retry.retryOn)? + !has(self.backendSettings.retry.retryOn.httpStatusCodes):true):true):true + redirectURL: + description: |- + The redirect URL to be used in the OIDC + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + If not specified, uses the default redirect URI "%REQ(x-forwarded-proto)%://%REQ(:authority)%/oauth2/callback" + type: string + refreshToken: + default: true + description: |- + RefreshToken indicates whether the Envoy should automatically refresh the + id token and access token when they expire. + When set to true, the Envoy will use the refresh token to get a new id token + and access token when they expire. + + If not specified, defaults to true. + type: boolean + resources: + description: |- + The OIDC resources to be used in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + items: + type: string + type: array + scopes: + description: |- + The OIDC scopes to be used in the + [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). + The "openid" scope is always added to the list of scopes if not already + specified. + items: + type: string + type: array + required: + - clientSecret + - provider + type: object + x-kubernetes-validations: + - message: only one of clientID or clientIDRef must + be set + rule: (has(self.clientID) && !has(self.clientIDRef)) + || (!has(self.clientID) && has(self.clientIDRef)) + - message: forwardAccessToken cannot be true when + forwardIDToken.header is Authorization + rule: '!(has(self.forwardAccessToken) && self.forwardAccessToken + && has(self.forwardIDToken) && self.forwardIDToken.header.lowerAscii() + == ''authorization'')' + required: + - oidc + type: object + spiffeJWTSVID: + description: |- + SPIFFEJWTSVID obtains the subject token as a JWT-SVID from the SPIFFE Workload API. + + The SPIFFE identity is owned by the controller, not by the policy: the JWT-SVID is issued for the + controller's SPIFFE ID. This differs from OIDCExchangeToken, where the policy supplies its own + credentials. Every policy using this source authenticates as the controller and can obtain access + tokens for any OpenAI service account mapped to the controller's SPIFFE ID, so only trusted cluster + admins should be allowed to create such policies. + properties: + audience: + description: |- + Audience is the audience ("aud" claim) of the requested JWT-SVID. The token exchange endpoint + validates it, so it must match the audience that endpoint expects, e.g. the audience configured + for the OpenAI Workload Identity Provider. + minLength: 1 + type: string + socketPath: + description: |- + SocketPath is the address of the SPIFFE Workload API, e.g. "unix:///run/spire/sockets/agent.sock". + When unset, the SPIFFE_ENDPOINT_SOCKET environment variable of the controller is used. + pattern: ^(unix|tcp)://.+ + type: string + required: + - audience + type: object + type: object + x-kubernetes-validations: + - message: Exactly one of oidcExchangeToken or spiffeJWTSVID + must be specified + rule: '[has(self.oidcExchangeToken), has(self.spiffeJWTSVID)].filter(x, + x).size() == 1' + required: + - identityProviderID + - serviceAccountID + - subjectToken + type: object + required: + - workloadIdentityFederationConfig + type: object targetRefs: description: |- TargetRefs are the names of the AIServiceBackend or InferencePool resources this BackendSecurityPolicy is being attached to. @@ -12875,6 +14952,7 @@ spec: - AzureCredentials - GCPCredentials - AnthropicAPIKey + - OpenAICredentials type: string required: - type @@ -12883,32 +14961,42 @@ spec: - message: When type is APIKey, only apiKey field should be set rule: 'self.type == ''APIKey'' ? (has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) && !has(self.azureCredentials) && !has(self.gcpCredentials) - && !has(self.anthropicAPIKey)) : true' + && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true' - message: When type is AWSCredentials, only awsCredentials field should be set rule: 'self.type == ''AWSCredentials'' ? (has(self.awsCredentials) && !has(self.apiKey) && !has(self.azureAPIKey) && !has(self.azureCredentials) - && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true' + && !has(self.gcpCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) + : true' - message: When type is AzureAPIKey, only azureAPIKey field should be set rule: 'self.type == ''AzureAPIKey'' ? (has(self.azureAPIKey) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureCredentials) && !has(self.gcpCredentials) - && !has(self.anthropicAPIKey)) : true' + && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) : true' - message: When type is AzureCredentials, only azureCredentials field should be set rule: 'self.type == ''AzureCredentials'' ? (has(self.azureCredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) - && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) : true' + && !has(self.gcpCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) + : true' - message: When type is GCPCredentials, only gcpCredentials field should be set rule: 'self.type == ''GCPCredentials'' ? (has(self.gcpCredentials) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) - && !has(self.azureCredentials) && !has(self.anthropicAPIKey)) : true' + && !has(self.azureCredentials) && !has(self.anthropicAPIKey) && !has(self.openAICredentials)) + : true' - message: When type is AnthropicAPIKey, only anthropicAPIKey field should be set rule: 'self.type == ''AnthropicAPIKey'' ? (has(self.anthropicAPIKey) && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) - && !has(self.azureCredentials) && !has(self.gcpCredentials)) : true' + && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.openAICredentials)) + : true' + - message: When type is OpenAICredentials, only openAICredentials field + should be set + rule: 'self.type == ''OpenAICredentials'' ? (has(self.openAICredentials) + && !has(self.apiKey) && !has(self.awsCredentials) && !has(self.azureAPIKey) + && !has(self.azureCredentials) && !has(self.gcpCredentials) && !has(self.anthropicAPIKey)) + : true' status: description: Status defines the status details of the BackendSecurityPolicy. properties: diff --git a/manifests/charts/ai-gateway-helm/values.yaml b/manifests/charts/ai-gateway-helm/values.yaml index 966fe102d7..5e47ce1ccd 100644 --- a/manifests/charts/ai-gateway-helm/values.yaml +++ b/manifests/charts/ai-gateway-helm/values.yaml @@ -203,6 +203,9 @@ controller: ## # Enabled by default for troubleshooting purposes given the impact is negligible when not in use. ## - name: DISABLE_PPROF ## value: "true" + ## # OpenAI Workload Identity Federation token exchange requests will use this proxy if set. + ## - name: AI_GATEWAY_OPENAI_PROXY_URL + ## value: "http://proxy.example.com:8080" extraEnvVars: [] # Example of volumes diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index a918cb811e..a40cd95551 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -3187,6 +3187,8 @@ MCPRouteList contains a list of MCPRoute. - [BackendSecurityPolicyCredentialOverride](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicycredentialoverride) - [BackendSecurityPolicyGCPCredentials](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicygcpcredentials) - [BackendSecurityPolicyOIDC](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyoidc) +- [BackendSecurityPolicyOpenAICredentials](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyopenaicredentials) +- [BackendSecurityPolicySPIFFEJWTSVID](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyspiffejwtsvid) - [BackendSecurityPolicySpec](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyspec) - [BackendSecurityPolicyStatus](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicystatus) - [BackendSecurityPolicyType](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicytype) @@ -3228,6 +3230,8 @@ MCPRouteList contains a list of MCPRoute. - [MCPRouteSpec](#github-com-envoyproxy-ai-gateway-api-v1beta1-mcproutespec) - [MCPRouteStatus](#github-com-envoyproxy-ai-gateway-api-v1beta1-mcproutestatus) - [MCPToolFilter](#github-com-envoyproxy-ai-gateway-api-v1beta1-mcptoolfilter) +- [OpenAISubjectToken](#github-com-envoyproxy-ai-gateway-api-v1beta1-openaisubjecttoken) +- [OpenAIWorkloadIdentityFederationConfig](#github-com-envoyproxy-ai-gateway-api-v1beta1-openaiworkloadidentityfederationconfig) - [ProtectedResourceMetadata](#github-com-envoyproxy-ai-gateway-api-v1beta1-protectedresourcemetadata) - [ToolCall](#github-com-envoyproxy-ai-gateway-api-v1beta1-toolcall) - [VersionedAPISchema](#github-com-envoyproxy-ai-gateway-api-v1beta1-versionedapischema) @@ -3878,6 +3882,7 @@ default service account credentials when running on GCP. - [AWSOIDCExchangeToken](#github-com-envoyproxy-ai-gateway-api-v1beta1-awsoidcexchangetoken) - [AzureOIDCExchangeToken](#github-com-envoyproxy-ai-gateway-api-v1beta1-azureoidcexchangetoken) - [GCPOIDCExchangeToken](#github-com-envoyproxy-ai-gateway-api-v1beta1-gcpoidcexchangetoken) +- [OpenAISubjectToken](#github-com-envoyproxy-ai-gateway-api-v1beta1-openaisubjecttoken) BackendSecurityPolicyOIDC specifies OIDC related fields. @@ -3903,6 +3908,63 @@ BackendSecurityPolicyOIDC specifies OIDC related fields. /> +#### BackendSecurityPolicyOpenAICredentials + + + +**Appears in:** +- [BackendSecurityPolicySpec](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyspec) + +BackendSecurityPolicyOpenAICredentials contains the supported authentication mechanisms to access OpenAI. + +##### Fields + + + + + + +#### BackendSecurityPolicySPIFFEJWTSVID + + + +**Appears in:** +- [OpenAISubjectToken](#github-com-envoyproxy-ai-gateway-api-v1beta1-openaisubjecttoken) + +BackendSecurityPolicySPIFFEJWTSVID specifies how to obtain a JWT-SVID from the SPIFFE Workload API. + +##### Fields + + + + + + #### BackendSecurityPolicySpec @@ -3959,6 +4021,11 @@ Only one type of BackendSecurityPolicy can be defined. type="[BackendSecurityPolicyAnthropicAPIKey](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyanthropicapikey)" required="false" description="AnthropicAPIKey is a mechanism to access Anthropic backend(s). The API key will be injected into the `x-api-key` header.
https://docs.claude.com/en/api/overview#authentication" +/> #### CredentialOverrideFromDynamicMetadata @@ -4082,7 +4154,7 @@ CredentialOverrideFromRequestHeaders sources the per-request credential from a r name="header" type="string" required="false" - description="Header is the name of the request header that carries the credential.
Defaults to the x-aigw-* header for the configured auth type:
APIKey → x-aigw-api-key
AnthropicAPIKey → x-aigw-anthropic-api-key
AzureAPIKey → x-aigw-azure-api-key
AzureCredentials → x-aigw-azure-access-token
GCPCredentials → x-aigw-gcp-access-token
For AWSCredentials this is a prefix, not a single header name, since SigV4 needs three
inputs. Defaults to `x-aigw-aws-`, yielding:
x-aigw-aws-access-key-id
x-aigw-aws-secret-access-key
x-aigw-aws-session-token (optional; omit for long-lived credentials)
All three are stripped before the backend sees them. An access key ID without a secret
access key, or the reverse, fails the request instead of falling back.
Prefer FromDynamicMetadata for AWSCredentials. Headers are client-settable, and a secret
access key on the wire is a worse leak than an API key." + description="Header is the name of the request header that carries the credential.
Defaults to the x-aigw-* header for the configured auth type:
APIKey → x-aigw-api-key
AnthropicAPIKey → x-aigw-anthropic-api-key
AzureAPIKey → x-aigw-azure-api-key
AzureCredentials → x-aigw-azure-access-token
GCPCredentials → x-aigw-gcp-access-token
OpenAICredentials → x-aigw-openai-access-token
For AWSCredentials this is a prefix, not a single header name, since SigV4 needs three
inputs. Defaults to `x-aigw-aws-`, yielding:
x-aigw-aws-access-key-id
x-aigw-aws-secret-access-key
x-aigw-aws-session-token (optional; omit for long-lived credentials)
All three are stripped before the backend sees them. An access key ID without a secret
access key, or the reverse, fails the request instead of falling back.
Prefer FromDynamicMetadata for AWSCredentials. Headers are client-settable, and a secret
access key on the wire is a worse leak than an API key." /> +#### OpenAISubjectToken + + + +**Appears in:** +- [OpenAIWorkloadIdentityFederationConfig](#github-com-envoyproxy-ai-gateway-api-v1beta1-openaiworkloadidentityfederationconfig) + +OpenAISubjectToken specifies the source of the subject token exchanged for an OpenAI access token. +Exactly one source must be configured. + +##### Fields + + + + + + +#### OpenAIWorkloadIdentityFederationConfig + + + +**Appears in:** +- [BackendSecurityPolicyOpenAICredentials](#github-com-envoyproxy-ai-gateway-api-v1beta1-backendsecuritypolicyopenaicredentials) + +OpenAIWorkloadIdentityFederationConfig specifies how the controller obtains an OpenAI access token via +OpenAI Workload Identity Federation. The controller obtains a subject token, exchanges it at +https://auth.openai.com/oauth/token for a short-lived OpenAI access token, and stores the access token in a secret, rotating it before it expires. + +The identity provider and service account mapping must be configured in the OpenAI organization, with the +issuer and audience of the subject token. + +https://developers.openai.com/api/reference/workload-identity-federation + +##### Fields + + + + + + #### ProtectedResourceMetadata diff --git a/tests/controller/openai_credentials_test.go b/tests/controller/openai_credentials_test.go new file mode 100644 index 0000000000..43a64921f0 --- /dev/null +++ b/tests/controller/openai_credentials_test.go @@ -0,0 +1,181 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package controller + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" + gwaiev1 "sigs.k8s.io/gateway-api-inference-extension/api/v1" + + aigv1b1 "github.com/envoyproxy/ai-gateway/api/v1beta1" + "github.com/envoyproxy/ai-gateway/internal/controller" + "github.com/envoyproxy/ai-gateway/internal/controller/rotators" + "github.com/envoyproxy/ai-gateway/internal/controller/tokenprovider" + "github.com/envoyproxy/ai-gateway/internal/json" + internaltesting "github.com/envoyproxy/ai-gateway/internal/testing" + testsinternal "github.com/envoyproxy/ai-gateway/tests/internal" +) + +const testControllerSPIFFEID = "spiffe://example.org/ns/envoy-ai-gateway-system/sa/ai-gateway-controller" + +// TestBackendSecurityPolicyController_OpenAICredentialsSPIFFE runs the BackendSecurityPolicy controller +// against a fake SPIFFE Workload API and a fake OpenAI token exchange endpoint, and checks that the exchanged +// access token ends up in the generated secret. +func TestBackendSecurityPolicyController_OpenAICredentialsSPIFFE(t *testing.T) { + c, cfg, k := testsinternal.NewEnvTest(t) + + workloadAPI := &fakeWorkloadAPI{t: t} + socketAddr := startFakeWorkloadAPI(t, workloadAPI) + + var ( + mu sync.Mutex + gotBody map[string]string + ) + exchangeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + mu.Lock() + gotBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":"openai-access-token","token_type":"Bearer","expires_in":3600,"expires_at":%d}`, time.Now().Add(time.Hour).Unix()) + })) + t.Cleanup(exchangeServer.Close) + origTokenURL := tokenprovider.OpenAITokenExchangeURL + tokenprovider.OpenAITokenExchangeURL = exchangeServer.URL + t.Cleanup(func() { tokenprovider.OpenAITokenExchangeURL = origTokenURL }) + + eventCh := internaltesting.NewControllerEventChan[*aigv1b1.AIServiceBackend]() + eventChPool := internaltesting.NewControllerEventChan[*gwaiev1.InferencePool]() + opt := ctrl.Options{Scheme: c.Scheme(), LeaderElection: false, Controller: config.Controller{SkipNameValidation: ptr.To(true)}} + mgr, err := ctrl.NewManager(cfg, opt) + require.NoError(t, err) + require.NoError(t, controller.ApplyIndexing(t.Context(), mgr.GetFieldIndexer().IndexField)) + pc := controller.NewBackendSecurityPolicyController(mgr.GetClient(), k, defaultLogger(), eventCh.Ch, eventChPool.Ch, controller.DefaultPreRotationWindow) + require.NoError(t, controller.TypedControllerBuilderForCRD(mgr, &aigv1b1.BackendSecurityPolicy{}).Complete(pc)) + go func() { require.NoError(t, mgr.Start(t.Context())) }() + require.True(t, mgr.GetCache().WaitForCacheSync(t.Context())) + + const bspName, bspNamespace = "openai-spiffe", "default" + require.NoError(t, c.Create(t.Context(), &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: bspName, Namespace: bspNamespace}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeOpenAICredentials, + OpenAICredentials: &aigv1b1.BackendSecurityPolicyOpenAICredentials{ + Organization: "org-123", + WorkloadIdentityFederationConfig: aigv1b1.OpenAIWorkloadIdentityFederationConfig{ + IdentityProviderID: "idp_123", + ServiceAccountID: "sa_456", + SubjectToken: aigv1b1.OpenAISubjectToken{ + SPIFFEJWTSVID: &aigv1b1.BackendSecurityPolicySPIFFEJWTSVID{ + Audience: "https://api.openai.com", + SocketPath: socketAddr, + }, + }, + }, + }, + }, + })) + + var secret corev1.Secret + require.Eventually(t, func() bool { + return c.Get(t.Context(), client.ObjectKey{Name: rotators.GetBSPSecretName(bspName), Namespace: bspNamespace}, &secret) == nil + }, 30*time.Second, 200*time.Millisecond) + require.Equal(t, "openai-access-token", string(secret.Data[rotators.OpenAIAccessTokenKey])) + expiresAt, err := rotators.GetExpirationSecretAnnotation(&secret) + require.NoError(t, err) + require.WithinDuration(t, time.Now().Add(time.Hour), expiresAt, time.Minute) + + require.Eventually(t, func() bool { + var bsp aigv1b1.BackendSecurityPolicy + require.NoError(t, c.Get(t.Context(), client.ObjectKey{Name: bspName, Namespace: bspNamespace}, &bsp)) + return len(bsp.Status.Conditions) == 1 && bsp.Status.Conditions[0].Type == aigv1b1.ConditionTypeAccepted + }, 30*time.Second, 200*time.Millisecond) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, "urn:ietf:params:oauth:token-type:jwt", gotBody["subject_token_type"]) + require.Equal(t, "idp_123", gotBody["identity_provider_id"]) + require.Equal(t, "sa_456", gotBody["service_account_id"]) + claims := jwt.RegisteredClaims{} + _, err = jwt.ParseWithClaims(gotBody["subject_token"], &claims, func(*jwt.Token) (any, error) { return &workloadAPI.key().PublicKey, nil }) + require.NoError(t, err) + require.Equal(t, testControllerSPIFFEID, claims.Subject) + require.Equal(t, jwt.ClaimStrings{"https://api.openai.com"}, claims.Audience) +} + +// fakeWorkloadAPI issues JWT-SVIDs for testControllerSPIFFEID. +type fakeWorkloadAPI struct { + workload.UnimplementedSpiffeWorkloadAPIServer + t *testing.T + once sync.Once + signKey *ecdsa.PrivateKey +} + +func (f *fakeWorkloadAPI) key() *ecdsa.PrivateKey { + f.once.Do(func() { + var err error + f.signKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(f.t, err) + }) + return f.signKey +} + +func (f *fakeWorkloadAPI) FetchJWTSVID(_ context.Context, req *workload.JWTSVIDRequest) (*workload.JWTSVIDResponse, error) { + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.RegisteredClaims{ + Subject: testControllerSPIFFEID, + Audience: req.Audience, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), + }) + signed, err := token.SignedString(f.key()) + if err != nil { + return nil, err + } + return &workload.JWTSVIDResponse{Svids: []*workload.JWTSVID{{SpiffeId: testControllerSPIFFEID, Svid: signed}}}, nil +} + +// startFakeWorkloadAPI serves the given server on a unix socket and returns its address. +func startFakeWorkloadAPI(t *testing.T, server workload.SpiffeWorkloadAPIServer) string { + t.Helper() + // Use a short directory since unix socket paths are limited to ~104 bytes on macOS. + dir, err := os.MkdirTemp("", "spiffe") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socketPath := filepath.Join(dir, "agent.sock") + + lis, err := net.Listen("unix", socketPath) + require.NoError(t, err) + s := grpc.NewServer() + workload.RegisterSpiffeWorkloadAPIServer(s, server) + go func() { _ = s.Serve(lis) }() + t.Cleanup(s.Stop) + return "unix://" + socketPath +} diff --git a/tests/crdcel/main_test.go b/tests/crdcel/main_test.go index 6a3511a104..75db2dd3e4 100644 --- a/tests/crdcel/main_test.go +++ b/tests/crdcel/main_test.go @@ -239,6 +239,32 @@ func TestBackendSecurityPolicies(t *testing.T) { {name: "aws_credential_override.yaml"}, {name: "gcp_oidc.yaml"}, {name: "anthropic-apikey.yaml"}, + {name: "openai_spiffe.yaml"}, + {name: "openai_oidc.yaml"}, + { + name: "openai_missing_subject_token.yaml", + expErr: "Exactly one of oidcExchangeToken or spiffeJWTSVID must be specified", + }, + { + name: "openai_multiple_subject_tokens.yaml", + expErr: "Exactly one of oidcExchangeToken or spiffeJWTSVID must be specified", + }, + { + name: "openai_missing_identity_provider_id.yaml", + expErr: "spec.openAICredentials.workloadIdentityFederationConfig.identityProviderID in body should be at least 1 chars long", + }, + { + name: "openai_invalid_socket_path.yaml", + expErr: "spec.openAICredentials.workloadIdentityFederationConfig.subjectToken.spiffeJWTSVID.socketPath in body should match", + }, + { + name: "openai_with_apikey.yaml", + expErr: "When type is OpenAICredentials, only openAICredentials field should be set", + }, + { + name: "apikey_with_openai_credentials.yaml", + expErr: "When type is APIKey, only apiKey field should be set", + }, {name: "targetrefs_basic.yaml"}, {name: "targetrefs_multiple.yaml"}, {name: "targetrefs_inferencepool.yaml"}, diff --git a/tests/crdcel/testdata/backendsecuritypolicies/apikey_with_openai_credentials.yaml b/tests/crdcel/testdata/backendsecuritypolicies/apikey_with_openai_credentials.yaml new file mode 100644 index 0000000000..108014c7d2 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/apikey_with_openai_credentials.yaml @@ -0,0 +1,22 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: apikey-with-openai-credentials-policy + namespace: default +spec: + type: APIKey + apiKey: + secretRef: + name: placeholder + openAICredentials: + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: + spiffeJWTSVID: + audience: https://api.openai.com diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_invalid_socket_path.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_invalid_socket_path.yaml new file mode 100644 index 0000000000..a01a09972b --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_invalid_socket_path.yaml @@ -0,0 +1,20 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-invalid-socket-path-policy + namespace: default +spec: + type: OpenAICredentials + openAICredentials: + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: + spiffeJWTSVID: + audience: https://api.openai.com + socketPath: /run/spire/sockets/agent.sock diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_missing_identity_provider_id.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_missing_identity_provider_id.yaml new file mode 100644 index 0000000000..ea5226cd37 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_missing_identity_provider_id.yaml @@ -0,0 +1,18 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-missing-identity-provider-id-policy + namespace: default +spec: + type: OpenAICredentials + openAICredentials: + workloadIdentityFederationConfig: + serviceAccountID: sa_placeholder + subjectToken: + spiffeJWTSVID: + audience: https://api.openai.com diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_missing_subject_token.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_missing_subject_token.yaml new file mode 100644 index 0000000000..386b82ace9 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_missing_subject_token.yaml @@ -0,0 +1,17 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-missing-subject-token-policy + namespace: default +spec: + type: OpenAICredentials + openAICredentials: + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: {} diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_multiple_subject_tokens.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_multiple_subject_tokens.yaml new file mode 100644 index 0000000000..7b96a7db26 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_multiple_subject_tokens.yaml @@ -0,0 +1,26 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-multiple-subject-tokens-policy + namespace: default +spec: + type: OpenAICredentials + openAICredentials: + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: + spiffeJWTSVID: + audience: https://api.openai.com + oidcExchangeToken: + oidc: + provider: + issuer: https://placeholder + clientID: placeholder + clientSecret: + name: placeholder diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_oidc.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_oidc.yaml new file mode 100644 index 0000000000..8bfdd441be --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_oidc.yaml @@ -0,0 +1,24 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-oidc-policy + namespace: default +spec: + type: OpenAICredentials + openAICredentials: + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: + oidcExchangeToken: + oidc: + provider: + issuer: https://placeholder + clientID: placeholder + clientSecret: + name: placeholder diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_spiffe.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_spiffe.yaml new file mode 100644 index 0000000000..1e9b70dfff --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_spiffe.yaml @@ -0,0 +1,22 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-spiffe-policy + namespace: default +spec: + type: OpenAICredentials + openAICredentials: + organization: org-placeholder + project: proj_placeholder + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: + spiffeJWTSVID: + audience: https://api.openai.com + socketPath: unix:///run/spire/sockets/agent.sock diff --git a/tests/crdcel/testdata/backendsecuritypolicies/openai_with_apikey.yaml b/tests/crdcel/testdata/backendsecuritypolicies/openai_with_apikey.yaml new file mode 100644 index 0000000000..a0583a06af --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/openai_with_apikey.yaml @@ -0,0 +1,22 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1beta1 +kind: BackendSecurityPolicy +metadata: + name: openai-with-apikey-policy + namespace: default +spec: + type: OpenAICredentials + apiKey: + secretRef: + name: placeholder + openAICredentials: + workloadIdentityFederationConfig: + identityProviderID: idp_placeholder + serviceAccountID: sa_placeholder + subjectToken: + spiffeJWTSVID: + audience: https://api.openai.com