diff --git a/.secrets.baseline b/.secrets.baseline index d2838a1317f..a0536178ad8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1172,7 +1172,7 @@ "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "e2fb052132fd6a07a56af2013e0b62a1f510572c", "is_verified": false, - "line_number": 224 + "line_number": 235 } ], "infra/feast-operator/internal/controller/services/services.go": [ @@ -1564,5 +1564,5 @@ } ] }, - "generated_at": "2026-07-30T09:40:48Z" + "generated_at": "2026-07-30T16:22:49Z" } diff --git a/docs/how-to-guides/feast-operator/05-security.md b/docs/how-to-guides/feast-operator/05-security.md index 4c28aba9805..002c3a9f846 100644 --- a/docs/how-to-guides/feast-operator/05-security.md +++ b/docs/how-to-guides/feast-operator/05-security.md @@ -39,7 +39,7 @@ to subjects using standard Kubernetes `ClusterRoleBinding` or `RoleBinding` reso > Kubernetes auth requires all services to be exposed as servers (the controller rejects > partial configurations where some services are local while RBAC is enabled). -**SDK docs**: [Feast RBAC](../reference/auth/rbac.md) +**SDK docs**: [Feast RBAC](../../getting-started/architecture/rbac.md) --- @@ -62,8 +62,20 @@ stringData: client_secret: username: # used for client-credentials flow password: + audience: # optional: reject tokens whose aud claim differs + issuer: # optional: reject tokens whose iss claim differs ``` +The optional `audience` and `issuer` keys enable audience and issuer claim verification on the standard OIDC/JWKS validation path; when omitted, the `aud` and `iss` claims are not checked. Set them to the values your IdP puts in the token itself, which are not always the ones in the discovery document (see [OIDC Authorization](../../getting-started/components/authz_manager.md#oidc-authorization)). The Secret key `issuer` is distinct from the CR's `issuerUrl`, which selects the discovery endpoint and plays no part in claim verification. Kubernetes ServiceAccount tokens (validated via TokenReview) and intra-server communication follow separate paths and are not subject to these checks. + +{% hint style="warning" %} +Before enabling these, three operational caveats: + +* **Existing Secret keys take effect on operator upgrade.** Keys named `audience` or `issuer` already present in the referenced Secret were previously ignored; after upgrading they are forwarded to every Feast pod. +* **Your IdP must mint matching tokens for Feast's own clients.** Feast's client-credentials flow requests no audience, so in multi-service topologies (e.g. a remote registry) and for the UI's browser tokens, the IdP must be configured to issue tokens carrying the expected claims (e.g. a Keycloak audience mapper), or inter-service calls will be rejected. +* **Secret edits are not watched.** Changes to these keys apply on the next reconcile or pod restart, not immediately. +{% endhint %} + Reference the Secret from the CR: ```yaml @@ -92,7 +104,7 @@ authz: caCertConfigMap: oidc-ca-cert # ConfigMap with CA cert for SSL verification ``` -**SDK docs**: [Feast OIDC Auth](../reference/auth/oidc.md) +**SDK docs**: [Feast OIDC Auth](../../getting-started/components/authz_manager.md#oidc-authorization) --- diff --git a/infra/feast-operator/config/samples/v1_featurestore_oidc_auth.yaml b/infra/feast-operator/config/samples/v1_featurestore_oidc_auth.yaml index 7ef676d0297..97f325bb0bd 100644 --- a/infra/feast-operator/config/samples/v1_featurestore_oidc_auth.yaml +++ b/infra/feast-operator/config/samples/v1_featurestore_oidc_auth.yaml @@ -19,3 +19,7 @@ stringData: client_secret: client_secret username: username password: password + # Optional: enable audience/issuer claim verification on the servers. + # Values must match the claims in the tokens your IdP issues. + # audience: api://feast-feature-server + # issuer: https://idp.example.com/realms/feast diff --git a/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go b/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go index e15f8ecfa8a..0f99f6d0479 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go +++ b/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go @@ -493,6 +493,8 @@ func expectedServerOidcAuthorizConfig() services.AuthzConfig { string(services.OidcClientSecret): "client-secret", string(services.OidcUsername): "username", string(services.OidcPassword): "password", + string(services.OidcAudience): "api://feast-feature-server", + string(services.OidcIssuer): "https://keycloak.example.com/realms/test", }, } } @@ -509,6 +511,8 @@ func validOidcSecretMap() map[string]string { string(services.OidcClientSecret): "client-secret", string(services.OidcUsername): "username", string(services.OidcPassword): "password", + string(services.OidcAudience): "api://feast-feature-server", + string(services.OidcIssuer): "https://keycloak.example.com/realms/test", } } diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 5df6bba3fbc..b4a01b312b1 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -138,6 +138,17 @@ func getBaseServiceRepoConfig( } for _, prop := range OidcOptionalSecretProperties { if val, exists := secretProperties[string(prop)]; exists { + // Secret values are YAML-parsed on extraction, so an + // all-digits audience or issuer arrives as an int and + // would render unquoted, which the SDK's OidcAuthConfig + // rejects (Optional[str]). Coerce the claim keys back to + // strings; the five original keys keep their historical + // typing. + if prop == OidcAudience || prop == OidcIssuer { + if _, isString := val.(string); !isString { + val = fmt.Sprintf("%v", val) + } + } oidcParameters[string(prop)] = val } } diff --git a/infra/feast-operator/internal/controller/services/repo_config_test.go b/infra/feast-operator/internal/controller/services/repo_config_test.go index 89941c6beb5..e87efdf7dec 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -212,16 +212,20 @@ var _ = Describe("Repo Config", func() { string(OidcClientId): clientIDValue, string(OidcClientSecret): "client-secret", string(OidcUsername): "username", - string(OidcPassword): "password"}) + string(OidcPassword): "password", + string(OidcAudience): "api://feast-feature-server", + string(OidcIssuer): "https://login.example.com/realms/master"}) repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) - Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(5)) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(7)) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcClientId))) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcAuthDiscoveryUrl))) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcClientSecret))) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcUsername))) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcPassword))) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKeyWithValue(string(OidcAudience), "api://feast-feature-server")) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKeyWithValue(string(OidcIssuer), "https://login.example.com/realms/master")) Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) Expect(repoConfig.OnlineStore).To(Equal(defaultOnlineStoreConfig(featureStore))) Expect(repoConfig.Registry).To(Equal(defaultRegistryConfig(featureStore))) @@ -229,6 +233,19 @@ var _ = Describe("Repo Config", func() { repoConfig = getClientRepoConfig(featureStore, nil) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) + By("Coercing numeric audience and issuer Secret values to strings") + secretExtractionFunc = mockOidcConfigFromSecret(map[string]interface{}{ + string(OidcAuthDiscoveryUrl): "discovery-url", + string(OidcClientId): clientIDValue, + // Secret extraction YAML-parses values, so an all-digits + // audience/issuer reaches this code as an int. + string(OidcAudience): 1234567890, + string(OidcIssuer): 9876543210}) + repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap, false) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKeyWithValue(string(OidcAudience), "1234567890")) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKeyWithValue(string(OidcIssuer), "9876543210")) + By("Having oidc authorization with issuerUrl only (no Secret)") featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ OidcAuthz: &feastdevv1.OidcAuthz{ diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index 06e614de087..f9b3303e3de 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -111,6 +111,8 @@ const ( OidcTokenEnvVar OidcPropertyType = "token_env_var" OidcVerifySsl OidcPropertyType = "verify_ssl" OidcCaCertPath OidcPropertyType = "ca_cert_path" + OidcAudience OidcPropertyType = "audience" + OidcIssuer OidcPropertyType = "issuer" OidcMissingSecretError string = "missing OIDC secret: %s" @@ -274,7 +276,7 @@ var ( }, } - OidcOptionalSecretProperties = []OidcPropertyType{OidcAuthDiscoveryUrl, OidcClientId, OidcClientSecret, OidcUsername, OidcPassword} + OidcOptionalSecretProperties = []OidcPropertyType{OidcAuthDiscoveryUrl, OidcClientId, OidcClientSecret, OidcUsername, OidcPassword, OidcAudience, OidcIssuer} ) // Feast server types: Reserved only for server types like Online, Offline, and Registry servers. Should not be used for client types like the UI, etc.