From 493a6b9962badd69165e0c4ad8124f1552d92bd9 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 10 Mar 2026 18:14:22 +0530 Subject: [PATCH 01/39] feat: Extract groups and namespaces claims from JWT in OidcTokenParser Signed-off-by: Aniket Paluskar --- .../permissions/auth/oidc_token_parser.py | 109 +++++++++----- .../permissions/auth/test_token_parser.py | 136 ++++++++++++++++++ 2 files changed, 206 insertions(+), 39 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index ffff7e7ad34..9a08b44bfef 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -50,12 +50,65 @@ async def _validate_token(self, access_token: str): await oauth_2_scheme(request=request) + @staticmethod + def _extract_username_or_raise_error(data: dict) -> str: + """Extract the username from the decoded JWT. Raises if missing — identity is mandatory.""" + if "preferred_username" not in data: + raise AuthenticationError( + "Missing preferred_username field in access token." + ) + return data["preferred_username"] + + def _extract_roles(self, data: dict) -> list[str]: + """Extract client-scoped roles from `resource_access..roles`.""" + if "resource_access" not in data: + logger.warning("Missing resource_access field in access token.") + return [] + client_id = self._auth_config.client_id + if client_id not in data["resource_access"]: + logger.warning( + f"Missing resource_access.{client_id} field in access token. Defaulting to empty roles." + ) + return [] + return data["resource_access"][client_id]["roles"] + + @staticmethod + def _extract_claim(data: dict, claim: str) -> list[str]: + """Extract an optional list-of-strings claim. Returns [] with a warning if missing.""" + if claim not in data: + logger.warning( + f"Missing {claim} field in access token. Defaulting to empty {claim}." + ) + return [] + return data[claim] + + def _decode_token(self, access_token: str) -> dict: + """Fetch the JWKS signing key and decode + verify the JWT.""" + optional_custom_headers = {"User-agent": "custom-user-agent"} + jwks_client = PyJWKClient( + self.oidc_discovery_service.get_jwks_url(), headers=optional_custom_headers + ) + signing_key = jwks_client.get_signing_key_from_jwt(access_token) + return jwt.decode( + access_token, + signing_key.key, + algorithms=["RS256"], + audience="account", + options={ + "verify_aud": False, + "verify_signature": True, + "verify_exp": True, + }, + leeway=10, # accepts tokens generated up to 10 seconds in the past, in case of clock skew + ) + async def user_details_from_access_token(self, access_token: str) -> User: """ - Validate the access token then decode it to extract the user credential and roles. + Validate the access token then decode it to extract the user credentials, + roles, groups, and namespaces. Returns: - User: Current user, with associated roles. + User: Current user, with associated roles, groups, and namespaces. Raises: AuthenticationError if any error happens. @@ -73,45 +126,23 @@ async def user_details_from_access_token(self, access_token: str) -> User: logger.error(f"Token validation failed: {e}") raise AuthenticationError(f"Invalid token: {e}") - optional_custom_headers = {"User-agent": "custom-user-agent"} - jwks_client = PyJWKClient( - self.oidc_discovery_service.get_jwks_url(), headers=optional_custom_headers - ) - try: - signing_key = jwks_client.get_signing_key_from_jwt(access_token) - data = jwt.decode( - access_token, - signing_key.key, - algorithms=["RS256"], - audience="account", - options={ - "verify_aud": False, - "verify_signature": True, - "verify_exp": True, - }, - leeway=10, # accepts tokens generated up to 10 seconds in the past, in case of clock skew - ) + data = self._decode_token(access_token) + + current_user = self._extract_username_or_raise_error(data) + roles = self._extract_roles(data) + groups = self._extract_claim(data, "groups") + namespaces = self._extract_claim(data, "namespaces") - if "preferred_username" not in data: - raise AuthenticationError( - "Missing preferred_username field in access token." - ) - current_user = data["preferred_username"] - - if "resource_access" not in data: - logger.warning("Missing resource_access field in access token.") - client_id = self._auth_config.client_id - if client_id not in data["resource_access"]: - logger.warning( - f"Missing resource_access.{client_id} field in access token. Defaulting to empty roles." - ) - roles = [] - else: - roles = data["resource_access"][client_id]["roles"] - - logger.info(f"Extracted user {current_user} and roles {roles}") - return User(username=current_user, roles=roles) + logger.info( + f"Extracted user {current_user} with roles {roles}, groups {groups}, namespaces {namespaces}" + ) + return User( + username=current_user, + roles=roles, + groups=groups, + namespaces=namespaces, + ) except jwt.exceptions.InvalidTokenError: logger.exception("Exception while parsing the token:") raise AuthenticationError("Invalid token.") diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 8ac0f2b6d5e..1c1c723d097 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -54,6 +54,142 @@ def test_oidc_token_validation_success( assertpy.assert_that(user.has_matching_role(["reader"])).is_true() assertpy.assert_that(user.has_matching_role(["writer"])).is_true() assertpy.assert_that(user.has_matching_role(["updater"])).is_false() + assertpy.assert_that(user.groups).is_equal_to([]) + assertpy.assert_that(user.namespaces).is_equal_to([]) + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_token_extracts_groups( + mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config +): + signing_key = MagicMock() + signing_key.key = "a-key" + mock_signing_key.return_value = signing_key + + mock_discovery_data.return_value = { + "authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth", + "token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token", + "jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs", + } + + user_data = { + "preferred_username": "my-name", + "resource_access": {_CLIENT_ID: {"roles": ["reader"]}}, + "groups": ["banking-admin", "data-engineers"], + } + mock_jwt.return_value = user_data + + access_token = "aaa-bbb-ccc" + token_parser = OidcTokenParser(auth_config=oidc_config) + user = asyncio.run( + token_parser.user_details_from_access_token(access_token=access_token) + ) + + assertpy.assert_that(user).is_type_of(User) + if isinstance(user, User): + assertpy.assert_that(user.groups).is_equal_to( + ["banking-admin", "data-engineers"] + ) + assertpy.assert_that(user.has_matching_group(["banking-admin"])).is_true() + assertpy.assert_that(user.has_matching_group(["unknown-group"])).is_false() + assertpy.assert_that(user.namespaces).is_equal_to([]) + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_token_extracts_namespaces( + mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config +): + signing_key = MagicMock() + signing_key.key = "a-key" + mock_signing_key.return_value = signing_key + + mock_discovery_data.return_value = { + "authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth", + "token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token", + "jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs", + } + + user_data = { + "preferred_username": "my-name", + "resource_access": {_CLIENT_ID: {"roles": ["reader"]}}, + "namespaces": ["production", "staging"], + } + mock_jwt.return_value = user_data + + access_token = "aaa-bbb-ccc" + token_parser = OidcTokenParser(auth_config=oidc_config) + user = asyncio.run( + token_parser.user_details_from_access_token(access_token=access_token) + ) + + assertpy.assert_that(user).is_type_of(User) + if isinstance(user, User): + assertpy.assert_that(user.namespaces).is_equal_to(["production", "staging"]) + assertpy.assert_that( + user.has_matching_namespace(["production"]) + ).is_true() + assertpy.assert_that( + user.has_matching_namespace(["unknown-ns"]) + ).is_false() + assertpy.assert_that(user.groups).is_equal_to([]) + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_token_extracts_groups_and_namespaces( + mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config +): + signing_key = MagicMock() + signing_key.key = "a-key" + mock_signing_key.return_value = signing_key + + mock_discovery_data.return_value = { + "authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth", + "token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token", + "jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs", + } + + user_data = { + "preferred_username": "my-name", + "resource_access": {_CLIENT_ID: {"roles": ["reader", "writer"]}}, + "groups": ["banking-admin", "data-engineers"], + "namespaces": ["production", "staging"], + } + mock_jwt.return_value = user_data + + access_token = "aaa-bbb-ccc" + token_parser = OidcTokenParser(auth_config=oidc_config) + user = asyncio.run( + token_parser.user_details_from_access_token(access_token=access_token) + ) + + assertpy.assert_that(user).is_type_of(User) + if isinstance(user, User): + assertpy.assert_that(user.username).is_equal_to("my-name") + assertpy.assert_that(user.roles.sort()).is_equal_to(["reader", "writer"].sort()) + assertpy.assert_that(user.groups).is_equal_to( + ["banking-admin", "data-engineers"] + ) + assertpy.assert_that(user.namespaces).is_equal_to(["production", "staging"]) + assertpy.assert_that(user.has_matching_role(["reader"])).is_true() + assertpy.assert_that(user.has_matching_group(["banking-admin"])).is_true() + assertpy.assert_that( + user.has_matching_namespace(["production"]) + ).is_true() @patch( From 3db6db311b838cf29bbd4ce5d425e40456a5e97d Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 10 Mar 2026 19:15:23 +0530 Subject: [PATCH 02/39] Minor formatting Signed-off-by: Aniket Paluskar --- .../tests/unit/permissions/auth/test_token_parser.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 1c1c723d097..9f0a7b3424e 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -135,12 +135,8 @@ def test_oidc_token_extracts_namespaces( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.namespaces).is_equal_to(["production", "staging"]) - assertpy.assert_that( - user.has_matching_namespace(["production"]) - ).is_true() - assertpy.assert_that( - user.has_matching_namespace(["unknown-ns"]) - ).is_false() + assertpy.assert_that(user.has_matching_namespace(["production"])).is_true() + assertpy.assert_that(user.has_matching_namespace(["unknown-ns"])).is_false() assertpy.assert_that(user.groups).is_equal_to([]) @@ -187,9 +183,7 @@ def test_oidc_token_extracts_groups_and_namespaces( assertpy.assert_that(user.namespaces).is_equal_to(["production", "staging"]) assertpy.assert_that(user.has_matching_role(["reader"])).is_true() assertpy.assert_that(user.has_matching_group(["banking-admin"])).is_true() - assertpy.assert_that( - user.has_matching_namespace(["production"]) - ).is_true() + assertpy.assert_that(user.has_matching_namespace(["production"])).is_true() @patch( From 0d59eca0785695b6d493aaff04c415f39eb30f73 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 10 Mar 2026 23:08:12 +0530 Subject: [PATCH 03/39] feat: Allow Feast SDK to accept a pre-existing OIDC token without contacting the identity provider Signed-off-by: Aniket Paluskar --- sdk/python/feast/permissions/auth_model.py | 73 ++--- .../oidc_authentication_client_manager.py | 38 ++- sdk/python/feast/repo_config.py | 20 +- .../test_oidc_token_passthrough.py | 283 ++++++++++++++++++ 4 files changed, 352 insertions(+), 62 deletions(-) create mode 100644 sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 3690d62b728..4c5e6de1b53 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -1,21 +1,34 @@ -# -------------------------------------------------------------------- -# Extends OIDC client auth model with an optional `token` field. -# Works on Pydantic v2-only. -# -# Accepted credential sets (exactly **one** of): -# 1 pre-issued `token` -# 2 `client_secret` (client-credentials flow) -# 3 `username` + `password` + `client_secret` (ROPG) -# -------------------------------------------------------------------- from __future__ import annotations -from typing import Literal, Optional +from typing import Literal, Optional, Tuple from pydantic import ConfigDict, model_validator from feast.repo_config import FeastConfigBaseModel +def _check_mutually_exclusive(**groups: Tuple[object, ...]) -> None: + """Validate that at most one named group is configured, and completely. + + Each *group* is a tuple of field values. + A group is **active** if *any* value in its tuple is truthy. + An active group is **valid** only if *all* its values are truthy. + At most one group may be active. + """ + active = {name: values for name, values in groups.items() if any(values)} + if len(active) > 1: + raise ValueError( + f"Only one of [{', '.join(groups)}] may be set, " + f"but got: {', '.join(active)}" + ) + for name, values in active.items(): + if not all(values): + raise ValueError( + f"Incomplete configuration for '{name}': " + f"all fields in this group are required when any is set." + ) + + class AuthConfig(FeastConfigBaseModel): type: Literal["oidc", "kubernetes", "no_auth"] = "no_auth" @@ -26,38 +39,27 @@ class OidcAuthConfig(AuthConfig): class OidcClientAuthConfig(OidcAuthConfig): - # any **one** of the four fields below is sufficient + auth_discovery_url: Optional[str] = None + client_id: Optional[str] = None + username: Optional[str] = None password: Optional[str] = None client_secret: Optional[str] = None - token: Optional[str] = None # pre-issued `token` + token: Optional[str] = None + token_env_var: Optional[str] = None @model_validator(mode="after") def _validate_credentials(self): - """Enforce exactly one valid credential set.""" - has_user_pass = bool(self.username) and bool(self.password) - has_secret = bool(self.client_secret) - has_token = bool(self.token) - - # 1 static token - if has_token and not (has_user_pass or has_secret): - return self - - # 2 client_credentials - if has_secret and not has_user_pass and not has_token: - return self - - # 3 ROPG - if has_user_pass and has_secret and not has_token: - return self - - raise ValueError( - "Invalid OIDC client auth combination: " - "provide either\n" - " • token\n" - " • client_secret (without username/password)\n" - " • username + password + client_secret" + network = (self.client_secret, self.auth_discovery_url, self.client_id) + if self.username or self.password: + network += (self.username, self.password) + + _check_mutually_exclusive( + token=(self.token,), + token_env_var=(self.token_env_var,), + client_credentials=network, ) + return self class NoAuthConfig(AuthConfig): @@ -65,7 +67,6 @@ class NoAuthConfig(AuthConfig): class KubernetesAuthConfig(AuthConfig): - # Optional user token for users (not service accounts) user_token: Optional[str] = None model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index e7db600cab5..31e292e76ea 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -17,24 +17,40 @@ def __init__(self, auth_config: OidcClientAuthConfig): def get_token(self): intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") - # If intra server communication call if intra_communication_base64: payload = { - "preferred_username": f"{intra_communication_base64}", # Subject claim + "preferred_username": f"{intra_communication_base64}", } - return jwt.encode(payload, "") - # Fetch the token endpoint from the discovery URL + if self.auth_config.token: + return self.auth_config.token + + if self.auth_config.token_env_var: + env_token = os.getenv(self.auth_config.token_env_var) + if env_token: + return env_token + + if self.auth_config.client_secret: + return self._fetch_token_from_idp() + + env_token = os.getenv("FEAST_OIDC_TOKEN") + if env_token: + return env_token + + raise PermissionError( + "No OIDC token source configured. Provide one of: " + "'token', 'token_env_var', 'client_secret' (with " + "'auth_discovery_url' and 'client_id'), or set the " + "FEAST_OIDC_TOKEN environment variable." + ) + + def _fetch_token_from_idp(self) -> str: + """Obtain an access token via client_credentials or ROPG flow.""" token_endpoint = OIDCDiscoveryService( self.auth_config.auth_discovery_url ).get_token_url() - # 1) pre-issued JWT supplied in config - if getattr(self.auth_config, "token", None): - return self.auth_config.token - - # 2) client_credentials if self.auth_config.client_secret and not ( self.auth_config.username and self.auth_config.password ): @@ -43,7 +59,6 @@ def get_token(self): "client_id": self.auth_config.client_id, "client_secret": self.auth_config.client_secret, } - # 3) ROPG (username + password + client_secret) else: token_request_body = { "grant_type": "password", @@ -52,11 +67,12 @@ def get_token(self): "username": self.auth_config.username, "password": self.auth_config.password, } - headers = {"Content-Type": "application/x-www-form-urlencoded"} + headers = {"Content-Type": "application/x-www-form-urlencoded"} token_response = requests.post( token_endpoint, data=token_request_body, headers=headers ) + if token_response.status_code == 200: access_token = token_response.json()["access_token"] if not access_token: diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 93fb2070cfd..f72cecd23bc 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -407,22 +407,12 @@ def offline_store(self): def auth_config(self): if not self._auth: if isinstance(self.auth, Dict): - # treat this auth block as *client-side* OIDC when it matches - # 1) ROPG – username + password + client_secret - # 2) client-credentials – client_secret only - # 3) static token – token is_oidc_client = self.auth.get("type") == AuthType.OIDC.value and ( - ( - "username" in self.auth - and "password" in self.auth - and "client_secret" in self.auth - ) # 1 - or ( - "client_secret" in self.auth - and "username" not in self.auth - and "password" not in self.auth - ) # 2 - or ("token" in self.auth) # 3 + ("username" in self.auth and "password" in self.auth and "client_secret" in self.auth) + or ("client_secret" in self.auth and "username" not in self.auth and "password" not in self.auth) + or ("token" in self.auth) + or ("token_env_var" in self.auth) + or ("auth_discovery_url" not in self.auth) ) self._auth = get_auth_config_from_type( "oidc_client" if is_oidc_client else self.auth.get("type") diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py new file mode 100644 index 00000000000..a116778d03f --- /dev/null +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -0,0 +1,283 @@ +""" +Tests for OIDC client-side token passthrough feature. + +Covers: + - Config validation (OidcClientAuthConfig) + - Token manager (OidcAuthClientManager.get_token) + - Routing (RepoConfig.auth_config property) +""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from feast.permissions.auth_model import OidcClientAuthConfig +from feast.permissions.client.oidc_authentication_client_manager import ( + OidcAuthClientManager, +) +from feast.repo_config import RepoConfig + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +class TestOidcClientAuthConfigValidation: + + def test_bare_oidc_valid(self): + cfg = OidcClientAuthConfig(type="oidc") + assert cfg.token_env_var is None + assert cfg.auth_discovery_url is None + assert cfg.client_id is None + + def test_token_alone_valid(self): + cfg = OidcClientAuthConfig(type="oidc", token="eyJhbGciOiJSUzI1NiJ9.test") + assert cfg.token == "eyJhbGciOiJSUzI1NiJ9.test" + + def test_token_env_var_alone_valid(self): + cfg = OidcClientAuthConfig(type="oidc", token_env_var="MY_VAR") + assert cfg.token_env_var == "MY_VAR" + + def test_token_plus_custom_env_var_invalid(self): + with pytest.raises(ValueError, match="Only one of"): + OidcClientAuthConfig( + type="oidc", + token="eyJtoken", + token_env_var="MY_VAR", + ) + + def test_client_secret_without_discovery_url_invalid(self): + with pytest.raises(ValueError, match="Incomplete configuration for 'client_credentials'"): + OidcClientAuthConfig( + type="oidc", + client_secret="my-secret", + ) + + def test_full_client_credentials_valid(self): + cfg = OidcClientAuthConfig( + type="oidc", + client_secret="my-secret", + auth_discovery_url="https://idp.example.com/.well-known/openid-configuration", + client_id="feast-client", + ) + assert cfg.client_secret == "my-secret" + + def test_full_ropg_valid(self): + cfg = OidcClientAuthConfig( + type="oidc", + username="user1", + password="pass1", + client_secret="my-secret", + auth_discovery_url="https://idp.example.com/.well-known/openid-configuration", + client_id="feast-client", + ) + assert cfg.username == "user1" + + def test_ropg_without_discovery_url_invalid(self): + with pytest.raises(ValueError, match="Incomplete configuration for 'client_credentials'"): + OidcClientAuthConfig( + type="oidc", + username="user1", + password="pass1", + client_secret="my-secret", + ) + + def test_username_without_client_secret_invalid(self): + with pytest.raises(ValueError, match="Incomplete configuration for 'client_credentials'"): + OidcClientAuthConfig( + type="oidc", + username="user1", + password="pass1", + ) + + def test_token_plus_client_secret_invalid(self): + with pytest.raises(ValueError, match="Only one of"): + OidcClientAuthConfig( + type="oidc", + token="jwt", + client_secret="secret", + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + + +# --------------------------------------------------------------------------- +# Token manager +# --------------------------------------------------------------------------- + + +class TestOidcAuthClientManagerGetToken: + + def _make_manager(self, **kwargs) -> OidcAuthClientManager: + cfg = OidcClientAuthConfig(type="oidc", **kwargs) + return OidcAuthClientManager(cfg) + + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_token_returned_directly(self, mock_discovery_cls): + mgr = self._make_manager(token="my-static-jwt") + assert mgr.get_token() == "my-static-jwt" + mock_discovery_cls.assert_not_called() + + def test_no_token_source_raises(self): + mgr = self._make_manager() + with pytest.raises(PermissionError, match="No OIDC token source configured"): + mgr.get_token() + + @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "env-jwt-value"}) + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_explicit_feast_env_var(self, mock_discovery_cls): + mgr = self._make_manager(token_env_var="FEAST_OIDC_TOKEN") + assert mgr.get_token() == "env-jwt-value" + mock_discovery_cls.assert_not_called() + + @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "fallback-jwt"}) + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_bare_config_falls_back_to_well_known_env(self, mock_discovery_cls): + mgr = self._make_manager() + assert mgr.get_token() == "fallback-jwt" + mock_discovery_cls.assert_not_called() + + @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "should-not-win"}, clear=False) + @patch("feast.permissions.client.oidc_authentication_client_manager.requests") + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_network_config_not_overridden_by_well_known_env( + self, mock_discovery_cls, mock_requests + ): + mock_discovery_instance = MagicMock() + mock_discovery_instance.get_token_url.return_value = "https://idp/token" + mock_discovery_cls.return_value = mock_discovery_instance + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "idp-token"} + mock_requests.post.return_value = mock_response + + mgr = self._make_manager( + client_secret="secret", + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + assert mgr.get_token() == "idp-token" + + @patch.dict(os.environ, {"CUSTOM_TOKEN_VAR": "custom-env-jwt"}) + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_custom_env_var_read(self, mock_discovery_cls): + mgr = self._make_manager(token_env_var="CUSTOM_TOKEN_VAR") + assert mgr.get_token() == "custom-env-jwt" + mock_discovery_cls.assert_not_called() + + @patch.dict(os.environ, {}, clear=False) + @patch("feast.permissions.client.oidc_authentication_client_manager.requests") + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_fallthrough_to_client_credentials(self, mock_discovery_cls, mock_requests): + os.environ.pop("FEAST_OIDC_TOKEN", None) + + mock_discovery_instance = MagicMock() + mock_discovery_instance.get_token_url.return_value = "https://idp/token" + mock_discovery_cls.return_value = mock_discovery_instance + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "network-token"} + mock_requests.post.return_value = mock_response + + mgr = self._make_manager( + client_secret="secret", + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + assert mgr.get_token() == "network-token" + mock_discovery_cls.assert_called_once() + + @patch.dict(os.environ, {}, clear=False) + @patch("feast.permissions.client.oidc_authentication_client_manager.requests") + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_ropg_flow(self, mock_discovery_cls, mock_requests): + os.environ.pop("FEAST_OIDC_TOKEN", None) + + mock_discovery_instance = MagicMock() + mock_discovery_instance.get_token_url.return_value = "https://idp/token" + mock_discovery_cls.return_value = mock_discovery_instance + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "ropg-token"} + mock_requests.post.return_value = mock_response + + mgr = self._make_manager( + username="user1", + password="pass1", + client_secret="secret", + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + assert mgr.get_token() == "ropg-token" + + call_args = mock_requests.post.call_args + assert call_args[1]["data"]["grant_type"] == "password" + assert call_args[1]["data"]["username"] == "user1" + + @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + def test_token_takes_priority_over_env_var(self, mock_discovery_cls): + with patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "env-token"}): + mgr = self._make_manager(token="config-token") + assert mgr.get_token() == "config-token" + mock_discovery_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# Routing (RepoConfig.auth_config property) +# --------------------------------------------------------------------------- + + +class TestOidcClientRouting: + + def _make_repo_config(self, auth_dict: dict) -> RepoConfig: + return RepoConfig( + project="test_project", + registry="data/registry.db", + provider="local", + auth=auth_dict, + ) + + def test_bare_oidc_routes_to_client(self): + rc = self._make_repo_config({"type": "oidc"}) + assert isinstance(rc.auth_config, OidcClientAuthConfig) + + def test_token_routes_to_client(self): + rc = self._make_repo_config({"type": "oidc", "token": "x"}) + assert isinstance(rc.auth_config, OidcClientAuthConfig) + assert rc.auth_config.token == "x" + + def test_token_env_var_routes_to_client(self): + rc = self._make_repo_config({"type": "oidc", "token_env_var": "MY_VAR"}) + assert isinstance(rc.auth_config, OidcClientAuthConfig) + assert rc.auth_config.token_env_var == "MY_VAR" + + def test_server_config_routes_to_oidc_auth_config(self): + from feast.permissions.auth_model import OidcAuthConfig + + rc = self._make_repo_config( + { + "type": "oidc", + "auth_discovery_url": "https://idp/.well-known/openid-configuration", + "client_id": "feast-server", + } + ) + assert isinstance(rc.auth_config, OidcAuthConfig) + assert type(rc.auth_config) is OidcAuthConfig + + def test_ropg_routes_to_client(self): + rc = self._make_repo_config( + { + "type": "oidc", + "auth_discovery_url": "https://idp/.well-known/openid-configuration", + "client_id": "feast-client", + "client_secret": "secret", + "username": "user1", + "password": "pass1", + } + ) + assert isinstance(rc.auth_config, OidcClientAuthConfig) From 36a5b06c32b292cb483f561b3571009bf29718e8 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 10 Mar 2026 23:45:01 +0530 Subject: [PATCH 04/39] fix: Raise error when configured token_env_var is empty Signed-off-by: Aniket Paluskar --- .../client/oidc_authentication_client_manager.py | 4 ++++ .../permissions/test_oidc_token_passthrough.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index 31e292e76ea..3020c05de0b 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -30,6 +30,10 @@ def get_token(self): env_token = os.getenv(self.auth_config.token_env_var) if env_token: return env_token + raise PermissionError( + f"token_env_var='{self.auth_config.token_env_var}' is configured " + f"but the environment variable is not set or is empty." + ) if self.auth_config.client_secret: return self._fetch_token_from_idp() diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py index a116778d03f..ad4a161f338 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -226,6 +226,20 @@ def test_token_takes_priority_over_env_var(self, mock_discovery_cls): assert mgr.get_token() == "config-token" mock_discovery_cls.assert_not_called() + @patch.dict(os.environ, {}, clear=False) + def test_configured_env_var_missing_raises(self): + os.environ.pop("MY_CUSTOM_VAR", None) + mgr = self._make_manager(token_env_var="MY_CUSTOM_VAR") + with pytest.raises(PermissionError, match="token_env_var='MY_CUSTOM_VAR'"): + mgr.get_token() + + @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "stale-token"}, clear=False) + def test_configured_env_var_missing_does_not_fall_through(self): + os.environ.pop("MY_CUSTOM_VAR", None) + mgr = self._make_manager(token_env_var="MY_CUSTOM_VAR") + with pytest.raises(PermissionError, match="token_env_var='MY_CUSTOM_VAR'"): + mgr.get_token() + # --------------------------------------------------------------------------- # Routing (RepoConfig.auth_config property) From a478a802c6556afb8e0f8fe359780ecf36ab2f7d Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 10 Mar 2026 23:48:43 +0530 Subject: [PATCH 05/39] Minor formatting changes Signed-off-by: Aniket Paluskar --- sdk/python/feast/repo_config.py | 12 ++++- .../test_oidc_token_passthrough.py | 48 +++++++++++++------ 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index f72cecd23bc..546ca26c5af 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -408,8 +408,16 @@ def auth_config(self): if not self._auth: if isinstance(self.auth, Dict): is_oidc_client = self.auth.get("type") == AuthType.OIDC.value and ( - ("username" in self.auth and "password" in self.auth and "client_secret" in self.auth) - or ("client_secret" in self.auth and "username" not in self.auth and "password" not in self.auth) + ( + "username" in self.auth + and "password" in self.auth + and "client_secret" in self.auth + ) + or ( + "client_secret" in self.auth + and "username" not in self.auth + and "password" not in self.auth + ) or ("token" in self.auth) or ("token_env_var" in self.auth) or ("auth_discovery_url" not in self.auth) diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py index ad4a161f338..164051f4478 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -18,14 +18,12 @@ ) from feast.repo_config import RepoConfig - # --------------------------------------------------------------------------- # Config validation # --------------------------------------------------------------------------- class TestOidcClientAuthConfigValidation: - def test_bare_oidc_valid(self): cfg = OidcClientAuthConfig(type="oidc") assert cfg.token_env_var is None @@ -49,7 +47,9 @@ def test_token_plus_custom_env_var_invalid(self): ) def test_client_secret_without_discovery_url_invalid(self): - with pytest.raises(ValueError, match="Incomplete configuration for 'client_credentials'"): + with pytest.raises( + ValueError, match="Incomplete configuration for 'client_credentials'" + ): OidcClientAuthConfig( type="oidc", client_secret="my-secret", @@ -76,7 +76,9 @@ def test_full_ropg_valid(self): assert cfg.username == "user1" def test_ropg_without_discovery_url_invalid(self): - with pytest.raises(ValueError, match="Incomplete configuration for 'client_credentials'"): + with pytest.raises( + ValueError, match="Incomplete configuration for 'client_credentials'" + ): OidcClientAuthConfig( type="oidc", username="user1", @@ -85,7 +87,9 @@ def test_ropg_without_discovery_url_invalid(self): ) def test_username_without_client_secret_invalid(self): - with pytest.raises(ValueError, match="Incomplete configuration for 'client_credentials'"): + with pytest.raises( + ValueError, match="Incomplete configuration for 'client_credentials'" + ): OidcClientAuthConfig( type="oidc", username="user1", @@ -109,12 +113,13 @@ def test_token_plus_client_secret_invalid(self): class TestOidcAuthClientManagerGetToken: - def _make_manager(self, **kwargs) -> OidcAuthClientManager: cfg = OidcClientAuthConfig(type="oidc", **kwargs) return OidcAuthClientManager(cfg) - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_token_returned_directly(self, mock_discovery_cls): mgr = self._make_manager(token="my-static-jwt") assert mgr.get_token() == "my-static-jwt" @@ -126,14 +131,18 @@ def test_no_token_source_raises(self): mgr.get_token() @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "env-jwt-value"}) - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_explicit_feast_env_var(self, mock_discovery_cls): mgr = self._make_manager(token_env_var="FEAST_OIDC_TOKEN") assert mgr.get_token() == "env-jwt-value" mock_discovery_cls.assert_not_called() @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "fallback-jwt"}) - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_bare_config_falls_back_to_well_known_env(self, mock_discovery_cls): mgr = self._make_manager() assert mgr.get_token() == "fallback-jwt" @@ -141,7 +150,9 @@ def test_bare_config_falls_back_to_well_known_env(self, mock_discovery_cls): @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "should-not-win"}, clear=False) @patch("feast.permissions.client.oidc_authentication_client_manager.requests") - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_network_config_not_overridden_by_well_known_env( self, mock_discovery_cls, mock_requests ): @@ -162,7 +173,9 @@ def test_network_config_not_overridden_by_well_known_env( assert mgr.get_token() == "idp-token" @patch.dict(os.environ, {"CUSTOM_TOKEN_VAR": "custom-env-jwt"}) - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_custom_env_var_read(self, mock_discovery_cls): mgr = self._make_manager(token_env_var="CUSTOM_TOKEN_VAR") assert mgr.get_token() == "custom-env-jwt" @@ -170,7 +183,9 @@ def test_custom_env_var_read(self, mock_discovery_cls): @patch.dict(os.environ, {}, clear=False) @patch("feast.permissions.client.oidc_authentication_client_manager.requests") - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_fallthrough_to_client_credentials(self, mock_discovery_cls, mock_requests): os.environ.pop("FEAST_OIDC_TOKEN", None) @@ -193,7 +208,9 @@ def test_fallthrough_to_client_credentials(self, mock_discovery_cls, mock_reques @patch.dict(os.environ, {}, clear=False) @patch("feast.permissions.client.oidc_authentication_client_manager.requests") - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_ropg_flow(self, mock_discovery_cls, mock_requests): os.environ.pop("FEAST_OIDC_TOKEN", None) @@ -219,7 +236,9 @@ def test_ropg_flow(self, mock_discovery_cls, mock_requests): assert call_args[1]["data"]["grant_type"] == "password" assert call_args[1]["data"]["username"] == "user1" - @patch("feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) def test_token_takes_priority_over_env_var(self, mock_discovery_cls): with patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "env-token"}): mgr = self._make_manager(token="config-token") @@ -247,7 +266,6 @@ def test_configured_env_var_missing_does_not_fall_through(self): class TestOidcClientRouting: - def _make_repo_config(self, auth_dict: dict) -> RepoConfig: return RepoConfig( project="test_project", From 1483c6c5bcc3c0781ec28c3693ba64e8e9a7b921 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 00:41:24 +0530 Subject: [PATCH 06/39] Activate _check_mutually_exclusive groups only when all fields are set, reject stray auth_discovery_url/client_id without client_secret Signed-off-by: Aniket Paluskar --- sdk/python/feast/permissions/auth_model.py | 20 ++++++++--------- .../test_oidc_token_passthrough.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 4c5e6de1b53..36fecf806d2 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -11,22 +11,22 @@ def _check_mutually_exclusive(**groups: Tuple[object, ...]) -> None: """Validate that at most one named group is configured, and completely. Each *group* is a tuple of field values. - A group is **active** if *any* value in its tuple is truthy. - An active group is **valid** only if *all* its values are truthy. - At most one group may be active. + A group is **active** only when *all* its values are truthy. + A group is **partial** (error) when *any* but not *all* values are truthy. + At most one active group may exist. """ - active = {name: values for name, values in groups.items() if any(values)} + partial = [name for name, vals in groups.items() if any(vals) and not all(vals)] + if partial: + raise ValueError( + f"Incomplete configuration for '{partial[0]}': " + f"all fields in this group are required when any is set." + ) + active = [name for name, vals in groups.items() if all(vals)] if len(active) > 1: raise ValueError( f"Only one of [{', '.join(groups)}] may be set, " f"but got: {', '.join(active)}" ) - for name, values in active.items(): - if not all(values): - raise ValueError( - f"Incomplete configuration for '{name}': " - f"all fields in this group are required when any is set." - ) class AuthConfig(FeastConfigBaseModel): diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py index 164051f4478..799066d57e6 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -106,6 +106,28 @@ def test_token_plus_client_secret_invalid(self): client_id="feast-client", ) + def test_token_env_var_with_discovery_url_invalid(self): + with pytest.raises( + ValueError, match="Incomplete configuration for 'client_credentials'" + ): + OidcClientAuthConfig( + type="oidc", + token_env_var="MY_VAR", + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + + def test_token_with_discovery_url_invalid(self): + with pytest.raises( + ValueError, match="Incomplete configuration for 'client_credentials'" + ): + OidcClientAuthConfig( + type="oidc", + token="eyJ.test", + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + # --------------------------------------------------------------------------- # Token manager From 34474afaa4c90184cf02796a4d47c1bea60fb385 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 15:15:17 +0530 Subject: [PATCH 07/39] Narrow OIDC client routing to use set-based key detection and extract _is_oidc_client_config helper Signed-off-by: Aniket Paluskar --- sdk/python/feast/repo_config.py | 38 ++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 546ca26c5af..babc70ba692 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -120,6 +120,22 @@ "oidc_client": "feast.permissions.auth_model.OidcClientAuthConfig", } +_OIDC_CLIENT_KEYS = frozenset({"client_secret", "token", "token_env_var"}) + + +def _is_oidc_client_config(auth_dict: dict) -> bool: + """Decide whether an OIDC auth dict should be routed to OidcClientAuthConfig. + + True when the dict carries any client-credential key, or when it is a bare + ``{"type": "oidc"}`` dict with no server-side keys (auth_discovery_url / + client_id), which signals token-passthrough via FEAST_OIDC_TOKEN. + """ + if auth_dict.get("type") != AuthType.OIDC.value: + return False + has_client_keys = bool(_OIDC_CLIENT_KEYS & auth_dict.keys()) + has_server_keys = "auth_discovery_url" in auth_dict or "client_id" in auth_dict + return has_client_keys or not has_server_keys + class FeastBaseModel(BaseModel): """Feast Pydantic Configuration Class""" @@ -407,24 +423,12 @@ def offline_store(self): def auth_config(self): if not self._auth: if isinstance(self.auth, Dict): - is_oidc_client = self.auth.get("type") == AuthType.OIDC.value and ( - ( - "username" in self.auth - and "password" in self.auth - and "client_secret" in self.auth - ) - or ( - "client_secret" in self.auth - and "username" not in self.auth - and "password" not in self.auth - ) - or ("token" in self.auth) - or ("token_env_var" in self.auth) - or ("auth_discovery_url" not in self.auth) + config_type = ( + "oidc_client" + if _is_oidc_client_config(self.auth) + else self.auth.get("type") ) - self._auth = get_auth_config_from_type( - "oidc_client" if is_oidc_client else self.auth.get("type") - )(**self.auth) + self._auth = get_auth_config_from_type(config_type)(**self.auth) elif isinstance(self.auth, str): self._auth = get_auth_config_from_type(self.auth)() elif self.auth: From 505d6de174bda9cd911b3b4c5a6f3b02fce07d49 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 15:19:26 +0530 Subject: [PATCH 08/39] Fix .sort() assertions in test_token_parser.py that always compared None == None Signed-off-by: Aniket Paluskar --- .../tests/unit/permissions/auth/test_token_parser.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 9f0a7b3424e..96bb7809bbe 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -50,7 +50,7 @@ def test_oidc_token_validation_success( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to("my-name") - assertpy.assert_that(user.roles.sort()).is_equal_to(["reader", "writer"].sort()) + assertpy.assert_that(sorted(user.roles)).is_equal_to(sorted(["reader", "writer"])) assertpy.assert_that(user.has_matching_role(["reader"])).is_true() assertpy.assert_that(user.has_matching_role(["writer"])).is_true() assertpy.assert_that(user.has_matching_role(["updater"])).is_false() @@ -176,7 +176,7 @@ def test_oidc_token_extracts_groups_and_namespaces( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to("my-name") - assertpy.assert_that(user.roles.sort()).is_equal_to(["reader", "writer"].sort()) + assertpy.assert_that(sorted(user.roles)).is_equal_to(sorted(["reader", "writer"])) assertpy.assert_that(user.groups).is_equal_to( ["banking-admin", "data-engineers"] ) @@ -261,8 +261,8 @@ async def mock_oath2(self, request): assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to("my-name") - assertpy.assert_that(user.roles.sort()).is_equal_to( - ["reader", "writer"].sort() + assertpy.assert_that(sorted(user.roles)).is_equal_to( + sorted(["reader", "writer"]) ) assertpy.assert_that(user.has_matching_role(["reader"])).is_true() assertpy.assert_that(user.has_matching_role(["writer"])).is_true() @@ -305,7 +305,7 @@ def test_k8s_token_validation_success( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to(f"{sa_namespace}:{sa_name}") - assertpy.assert_that(user.roles.sort()).is_equal_to(roles.sort()) + assertpy.assert_that(sorted(user.roles)).is_equal_to(sorted(roles)) for r in roles: assertpy.assert_that(user.has_matching_role([r])).is_true() assertpy.assert_that(user.has_matching_role(["foo"])).is_false() @@ -389,7 +389,7 @@ def test_k8s_inter_server_comm( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to(f"{sa_namespace}:{sa_name}") - assertpy.assert_that(user.roles.sort()).is_equal_to(roles.sort()) + assertpy.assert_that(sorted(user.roles)).is_equal_to(sorted(roles)) for r in roles: assertpy.assert_that(user.has_matching_role([r])).is_true() assertpy.assert_that(user.has_matching_role(["foo"])).is_false() From 5c79fcffbff7213fb291442372e413156b5cb63a Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 15:25:16 +0530 Subject: [PATCH 09/39] Guard against missing roles key in resource_access to prevent unhandled KeyError Signed-off-by: Aniket Paluskar --- .../permissions/auth/oidc_token_parser.py | 8 +++- .../permissions/auth/test_token_parser.py | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 9a08b44bfef..145efcd442c 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -70,7 +70,13 @@ def _extract_roles(self, data: dict) -> list[str]: f"Missing resource_access.{client_id} field in access token. Defaulting to empty roles." ) return [] - return data["resource_access"][client_id]["roles"] + client_entry = data["resource_access"][client_id] + if "roles" not in client_entry: + logger.warning( + f"Missing resource_access.{client_id}.roles field in access token. Defaulting to empty roles." + ) + return [] + return client_entry["roles"] @staticmethod def _extract_claim(data: dict, claim: str) -> list[str]: diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 96bb7809bbe..f1921180dce 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -58,6 +58,43 @@ def test_oidc_token_validation_success( assertpy.assert_that(user.namespaces).is_equal_to([]) +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_token_missing_roles_key_returns_empty( + mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config +): + signing_key = MagicMock() + signing_key.key = "a-key" + mock_signing_key.return_value = signing_key + + mock_discovery_data.return_value = { + "authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth", + "token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token", + "jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs", + } + + user_data = { + "preferred_username": "my-name", + "resource_access": {_CLIENT_ID: {}}, + } + mock_jwt.return_value = user_data + + access_token = "aaa-bbb-ccc" + token_parser = OidcTokenParser(auth_config=oidc_config) + user = asyncio.run( + token_parser.user_details_from_access_token(access_token=access_token) + ) + + assertpy.assert_that(user).is_type_of(User) + if isinstance(user, User): + assertpy.assert_that(user.username).is_equal_to("my-name") + assertpy.assert_that(user.roles).is_equal_to([]) + + @patch( "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" ) From e0359dbc3369eabac3ad332877f71b19c2b80bf5 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 15:34:59 +0530 Subject: [PATCH 10/39] Fixed lint errors Signed-off-by: Aniket Paluskar --- .../permissions/auth/test_token_parser.py | 8 +++++-- .../test_oidc_token_passthrough.py | 24 +++++++++---------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index f1921180dce..e4271b5b787 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -50,7 +50,9 @@ def test_oidc_token_validation_success( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to("my-name") - assertpy.assert_that(sorted(user.roles)).is_equal_to(sorted(["reader", "writer"])) + assertpy.assert_that(sorted(user.roles)).is_equal_to( + sorted(["reader", "writer"]) + ) assertpy.assert_that(user.has_matching_role(["reader"])).is_true() assertpy.assert_that(user.has_matching_role(["writer"])).is_true() assertpy.assert_that(user.has_matching_role(["updater"])).is_false() @@ -213,7 +215,9 @@ def test_oidc_token_extracts_groups_and_namespaces( assertpy.assert_that(user).is_type_of(User) if isinstance(user, User): assertpy.assert_that(user.username).is_equal_to("my-name") - assertpy.assert_that(sorted(user.roles)).is_equal_to(sorted(["reader", "writer"])) + assertpy.assert_that(sorted(user.roles)).is_equal_to( + sorted(["reader", "writer"]) + ) assertpy.assert_that(user.groups).is_equal_to( ["banking-admin", "data-engineers"] ) diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py index 799066d57e6..603348cf67d 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -52,13 +52,13 @@ def test_client_secret_without_discovery_url_invalid(self): ): OidcClientAuthConfig( type="oidc", - client_secret="my-secret", + client_secret="my-secret", # pragma: allowlist secret ) def test_full_client_credentials_valid(self): cfg = OidcClientAuthConfig( type="oidc", - client_secret="my-secret", + client_secret="my-secret", # pragma: allowlist secret auth_discovery_url="https://idp.example.com/.well-known/openid-configuration", client_id="feast-client", ) @@ -68,8 +68,8 @@ def test_full_ropg_valid(self): cfg = OidcClientAuthConfig( type="oidc", username="user1", - password="pass1", - client_secret="my-secret", + password="pass1", # pragma: allowlist secret + client_secret="my-secret", # pragma: allowlist secret auth_discovery_url="https://idp.example.com/.well-known/openid-configuration", client_id="feast-client", ) @@ -82,8 +82,8 @@ def test_ropg_without_discovery_url_invalid(self): OidcClientAuthConfig( type="oidc", username="user1", - password="pass1", - client_secret="my-secret", + password="pass1", # pragma: allowlist secret + client_secret="my-secret", # pragma: allowlist secret ) def test_username_without_client_secret_invalid(self): @@ -93,7 +93,7 @@ def test_username_without_client_secret_invalid(self): OidcClientAuthConfig( type="oidc", username="user1", - password="pass1", + password="pass1", # pragma: allowlist secret ) def test_token_plus_client_secret_invalid(self): @@ -101,7 +101,7 @@ def test_token_plus_client_secret_invalid(self): OidcClientAuthConfig( type="oidc", token="jwt", - client_secret="secret", + client_secret="secret", # pragma: allowlist secret auth_discovery_url="https://idp/.well-known/openid-configuration", client_id="feast-client", ) @@ -188,7 +188,7 @@ def test_network_config_not_overridden_by_well_known_env( mock_requests.post.return_value = mock_response mgr = self._make_manager( - client_secret="secret", + client_secret="secret", # pragma: allowlist secret auth_discovery_url="https://idp/.well-known/openid-configuration", client_id="feast-client", ) @@ -221,7 +221,7 @@ def test_fallthrough_to_client_credentials(self, mock_discovery_cls, mock_reques mock_requests.post.return_value = mock_response mgr = self._make_manager( - client_secret="secret", + client_secret="secret", # pragma: allowlist secret auth_discovery_url="https://idp/.well-known/openid-configuration", client_id="feast-client", ) @@ -247,8 +247,8 @@ def test_ropg_flow(self, mock_discovery_cls, mock_requests): mgr = self._make_manager( username="user1", - password="pass1", - client_secret="secret", + password="pass1", # pragma: allowlist secret + client_secret="secret", # pragma: allowlist secret auth_discovery_url="https://idp/.well-known/openid-configuration", client_id="feast-client", ) From 745334934b0225468d91ccb46d9799e74e714984 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 15:55:49 +0530 Subject: [PATCH 11/39] Fixed lint error Signed-off-by: Aniket Paluskar --- .../tests/unit/permissions/test_oidc_token_passthrough.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py index 603348cf67d..77d15f1614b 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -62,7 +62,7 @@ def test_full_client_credentials_valid(self): auth_discovery_url="https://idp.example.com/.well-known/openid-configuration", client_id="feast-client", ) - assert cfg.client_secret == "my-secret" + assert cfg.client_secret == "my-secret" # pragma: allowlist secret def test_full_ropg_valid(self): cfg = OidcClientAuthConfig( @@ -329,9 +329,9 @@ def test_ropg_routes_to_client(self): "type": "oidc", "auth_discovery_url": "https://idp/.well-known/openid-configuration", "client_id": "feast-client", - "client_secret": "secret", + "client_secret": "secret", # pragma: allowlist secret "username": "user1", - "password": "pass1", + "password": "pass1", # pragma: allowlist secret } ) assert isinstance(rc.auth_config, OidcClientAuthConfig) From 4b4c1dd07a1e997d2d112dd11f8563d8359a54cb Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 16:19:52 +0530 Subject: [PATCH 12/39] Fixed lint errors Signed-off-by: Aniket Paluskar --- sdk/python/feast/permissions/auth_model.py | 4 ++-- .../permissions/client/oidc_authentication_client_manager.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 36fecf806d2..c20379d82a5 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -39,8 +39,8 @@ class OidcAuthConfig(AuthConfig): class OidcClientAuthConfig(OidcAuthConfig): - auth_discovery_url: Optional[str] = None - client_id: Optional[str] = None + auth_discovery_url: Optional[str] = None # type: ignore[assignment] + client_id: Optional[str] = None # type: ignore[assignment] username: Optional[str] = None password: Optional[str] = None diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index 3020c05de0b..6d225546827 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -51,6 +51,7 @@ def get_token(self): def _fetch_token_from_idp(self) -> str: """Obtain an access token via client_credentials or ROPG flow.""" + assert self.auth_config.auth_discovery_url is not None token_endpoint = OIDCDiscoveryService( self.auth_config.auth_discovery_url ).get_token_url() From 13534821c6824e090306fb7777d24ae7d2712cc1 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 11 Mar 2026 21:40:25 +0530 Subject: [PATCH 13/39] Added support to read ServiceAccount token and Minor improvements Signed-off-by: Aniket Paluskar --- .../permissions/auth/oidc_token_parser.py | 60 ++++++++++-- .../oidc_authentication_client_manager.py | 50 ++++++---- sdk/python/feast/permissions/server/utils.py | 18 +++- sdk/python/feast/repo_config.py | 2 +- .../permissions/auth/test_token_parser.py | 91 +++++++++++++++++- .../test_oidc_token_passthrough.py | 96 ++++++++++++++++++- 6 files changed, 290 insertions(+), 27 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 145efcd442c..6f3147c177e 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -21,14 +21,24 @@ class OidcTokenParser(TokenParser): """ - A `TokenParser` to use an OIDC server to retrieve the user details. - Server settings are retrieved from the `auth` configurationof the Feature store. + A ``TokenParser`` to use an OIDC server to retrieve the user details. + Server settings are retrieved from the ``auth`` configuration of the Feature store. + + When running inside Kubernetes, an optional ``k8s_parser`` can be supplied. + Incoming tokens that contain a ``kubernetes.io`` claim (i.e. Kubernetes + service-account tokens) are delegated to the K8s parser, while all other + tokens follow the standard OIDC/Keycloak JWKS validation path. """ _auth_config: OidcAuthConfig - def __init__(self, auth_config: OidcAuthConfig): + def __init__( + self, + auth_config: OidcAuthConfig, + k8s_parser: Optional[TokenParser] = None, + ): self._auth_config = auth_config + self._k8s_parser = k8s_parser self.oidc_discovery_service = OIDCDiscoveryService( self._auth_config.auth_discovery_url ) @@ -80,9 +90,9 @@ def _extract_roles(self, data: dict) -> list[str]: @staticmethod def _extract_claim(data: dict, claim: str) -> list[str]: - """Extract an optional list-of-strings claim. Returns [] with a warning if missing.""" + """Extract an optional list-of-strings claim. Returns [] if missing.""" if claim not in data: - logger.warning( + logger.debug( f"Missing {claim} field in access token. Defaulting to empty {claim}." ) return [] @@ -108,11 +118,43 @@ def _decode_token(self, access_token: str) -> dict: leeway=10, # accepts tokens generated up to 10 seconds in the past, in case of clock skew ) + async def _try_delegate_to_k8s_parser( + self, access_token: str + ) -> Optional[User]: + """Detect K8s service-account tokens and delegate to the K8s parser. + + Returns a ``User`` if the token was handled, or ``None`` if it should + continue through the standard OIDC path. + """ + if self._k8s_parser is None: + return None + + try: + unverified = jwt.decode( + access_token, options={"verify_signature": False} + ) + except jwt.exceptions.DecodeError as e: + raise AuthenticationError(f"Failed to decode token: {e}") + + if "kubernetes.io" not in unverified: + return None + + logger.debug( + "Detected kubernetes.io claim — delegating to KubernetesTokenParser" + ) + return await self._k8s_parser.user_details_from_access_token( + access_token + ) + async def user_details_from_access_token(self, access_token: str) -> User: """ Validate the access token then decode it to extract the user credentials, roles, groups, and namespaces. + Kubernetes service-account tokens (identified by the ``kubernetes.io`` + claim) are delegated to the K8s parser when available. All other tokens + follow the standard Keycloak JWKS validation path. + Returns: User: Current user, with associated roles, groups, and namespaces. @@ -125,6 +167,12 @@ async def user_details_from_access_token(self, access_token: str) -> User: if user: return user + # Detect K8s service-account tokens and delegate + user = await self._try_delegate_to_k8s_parser(access_token) + if user: + return user + + # Standard OIDC / Keycloak flow try: await self._validate_token(access_token) logger.debug("Token successfully validated.") @@ -138,7 +186,7 @@ async def user_details_from_access_token(self, access_token: str) -> User: current_user = self._extract_username_or_raise_error(data) roles = self._extract_roles(data) groups = self._extract_claim(data, "groups") - namespaces = self._extract_claim(data, "namespaces") + namespaces = self._extract_claim(data, "namespace") logger.info( f"Extracted user {current_user} with roles {roles}, groups {groups}, namespaces {namespaces}" diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index 6d225546827..7def2288702 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) +SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" + class OidcAuthClientManager(AuthenticationClientManager): def __init__(self, auth_config: OidcClientAuthConfig): @@ -25,29 +27,43 @@ def get_token(self): if self.auth_config.token: return self.auth_config.token - - if self.auth_config.token_env_var: + elif self.auth_config.token_env_var: env_token = os.getenv(self.auth_config.token_env_var) if env_token: return env_token - raise PermissionError( - f"token_env_var='{self.auth_config.token_env_var}' is configured " - f"but the environment variable is not set or is empty." - ) - - if self.auth_config.client_secret: + else: + raise PermissionError( + f"token_env_var='{self.auth_config.token_env_var}' is configured " + f"but the environment variable is not set or is empty." + ) + elif self.auth_config.client_secret: return self._fetch_token_from_idp() + else: + env_token = os.getenv("FEAST_OIDC_TOKEN") + if env_token: + return env_token - env_token = os.getenv("FEAST_OIDC_TOKEN") - if env_token: - return env_token + sa_token = self._read_sa_token() + if sa_token: + return sa_token - raise PermissionError( - "No OIDC token source configured. Provide one of: " - "'token', 'token_env_var', 'client_secret' (with " - "'auth_discovery_url' and 'client_id'), or set the " - "FEAST_OIDC_TOKEN environment variable." - ) + raise PermissionError( + "No OIDC token source configured. Provide one of: " + "'token', 'token_env_var', 'client_secret' (with " + "'auth_discovery_url' and 'client_id'), set the " + "FEAST_OIDC_TOKEN environment variable, or run inside " + "a Kubernetes pod with a mounted service account token." + ) + + @staticmethod + def _read_sa_token() -> str | None: + """Read the Kubernetes service account token from the standard mount path.""" + if os.path.isfile(SA_TOKEN_PATH): + with open(SA_TOKEN_PATH) as f: + token = f.read().strip() + if token: + return token + return None def _fetch_token_from_idp(self) -> str: """Obtain an access token via client_credentials or ROPG flow.""" diff --git a/sdk/python/feast/permissions/server/utils.py b/sdk/python/feast/permissions/server/utils.py index cd72ae58204..f76e48ab9fb 100644 --- a/sdk/python/feast/permissions/server/utils.py +++ b/sdk/python/feast/permissions/server/utils.py @@ -121,7 +121,23 @@ def init_auth_manager( token_parser = KubernetesTokenParser() elif auth_type == AuthManagerType.OIDC: assert isinstance(auth_config, OidcAuthConfig) - token_parser = OidcTokenParser(auth_config=auth_config) + k8s_parser = None + try: + from feast.permissions.auth.kubernetes_token_parser import ( + KubernetesTokenParser, + ) + + k8s_parser = KubernetesTokenParser() + logger.info( + "K8s API available — SA token support enabled for OIDC auth" + ) + except Exception as e: + logger.info( + f"K8s API unavailable ({e}) — OIDC-only token parsing" + ) + token_parser = OidcTokenParser( + auth_config=auth_config, k8s_parser=k8s_parser + ) else: raise ValueError(f"Unmanaged authorization manager type {auth_type}") diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index babc70ba692..77aeeb5a123 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -120,7 +120,7 @@ "oidc_client": "feast.permissions.auth_model.OidcClientAuthConfig", } -_OIDC_CLIENT_KEYS = frozenset({"client_secret", "token", "token_env_var"}) +_OIDC_CLIENT_KEYS = frozenset({"client_secret", "token", "token_env_var", "username", "password"}) def _is_oidc_client_config(auth_dict: dict) -> bool: diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index e4271b5b787..9b0622a3ba8 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -1,7 +1,7 @@ import asyncio import os from unittest import mock -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import assertpy import pytest @@ -434,3 +434,92 @@ def test_k8s_inter_server_comm( for r in roles: assertpy.assert_that(user.has_matching_role([r])).is_true() assertpy.assert_that(user.has_matching_role(["foo"])).is_false() + + +# --------------------------------------------------------------------------- +# OidcTokenParser — SA token routing +# --------------------------------------------------------------------------- + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_parser_routes_sa_token_to_k8s_parser( + mock_discovery_data, mock_jwt_decode, mock_signing_key, mock_oauth2, oidc_config +): + """When a token contains kubernetes.io claim, it should be routed to the K8s parser.""" + mock_discovery_data.return_value = { + "authorization_endpoint": "https://localhost:8080/auth", + "token_endpoint": "https://localhost:8080/token", + "jwks_uri": "https://localhost:8080/certs", + } + + sa_user = User( + username="feast:feast", + roles=[], + groups=["system:serviceaccounts:feast"], + namespaces=["feast"], + ) + + k8s_parser = MagicMock() + k8s_parser.user_details_from_access_token = AsyncMock(return_value=sa_user) + + # jwt.decode is patched globally — the unverified decode inside the parser + # returns a payload with kubernetes.io claim + mock_jwt_decode.return_value = { + "kubernetes.io": {"namespace": "feast"}, + "sub": "system:serviceaccount:feast:feast", + } + + token_parser = OidcTokenParser(auth_config=oidc_config, k8s_parser=k8s_parser) + user = asyncio.run( + token_parser.user_details_from_access_token(access_token="sa-token") + ) + + k8s_parser.user_details_from_access_token.assert_called_once_with("sa-token") + assertpy.assert_that(user.username).is_equal_to("feast:feast") + assertpy.assert_that(user.namespaces).is_equal_to(["feast"]) + mock_signing_key.assert_not_called() + + +@patch( + "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" +) +@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") +@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") +@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") +def test_oidc_parser_routes_keycloak_token_normally( + mock_discovery_data, mock_jwt_decode, mock_signing_key, mock_oauth2, oidc_config +): + """When a token does NOT contain kubernetes.io claim, it should follow the OIDC path.""" + signing_key = MagicMock() + signing_key.key = "a-key" + mock_signing_key.return_value = signing_key + + mock_discovery_data.return_value = { + "authorization_endpoint": "https://localhost:8080/auth", + "token_endpoint": "https://localhost:8080/token", + "jwks_uri": "https://localhost:8080/certs", + } + + keycloak_payload = { + "preferred_username": "testuser", + "resource_access": {_CLIENT_ID: {"roles": ["reader"]}}, + "groups": ["data-team"], + } + mock_jwt_decode.return_value = keycloak_payload + + k8s_parser = MagicMock() + + token_parser = OidcTokenParser(auth_config=oidc_config, k8s_parser=k8s_parser) + user = asyncio.run( + token_parser.user_details_from_access_token(access_token="keycloak-jwt") + ) + + k8s_parser.user_details_from_access_token.assert_not_called() + assertpy.assert_that(user.username).is_equal_to("testuser") + assertpy.assert_that(user.roles).is_equal_to(["reader"]) + assertpy.assert_that(user.groups).is_equal_to(["data-team"]) diff --git a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py index 77d15f1614b..e0766ad6b0e 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py +++ b/sdk/python/tests/unit/permissions/test_oidc_token_passthrough.py @@ -147,7 +147,11 @@ def test_token_returned_directly(self, mock_discovery_cls): assert mgr.get_token() == "my-static-jwt" mock_discovery_cls.assert_not_called() - def test_no_token_source_raises(self): + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OidcAuthClientManager._read_sa_token", + return_value=None, + ) + def test_no_token_source_raises(self, _mock_sa): mgr = self._make_manager() with pytest.raises(PermissionError, match="No OIDC token source configured"): mgr.get_token() @@ -281,6 +285,82 @@ def test_configured_env_var_missing_does_not_fall_through(self): with pytest.raises(PermissionError, match="token_env_var='MY_CUSTOM_VAR'"): mgr.get_token() + # --- SA token file fallback tests --- + + @patch.dict(os.environ, {}, clear=False) + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OidcAuthClientManager._read_sa_token", + return_value="sa-jwt-from-file", + ) + def test_sa_token_file_read(self, _mock_sa): + os.environ.pop("FEAST_OIDC_TOKEN", None) + mgr = self._make_manager() + assert mgr.get_token() == "sa-jwt-from-file" + + @patch.dict(os.environ, {}, clear=False) + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OidcAuthClientManager._read_sa_token", + return_value=None, + ) + def test_sa_token_file_missing_raises(self, _mock_sa): + os.environ.pop("FEAST_OIDC_TOKEN", None) + mgr = self._make_manager() + with pytest.raises(PermissionError, match="No OIDC token source configured"): + mgr.get_token() + + @patch.dict(os.environ, {"FEAST_OIDC_TOKEN": "env-token"}, clear=False) + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OidcAuthClientManager._read_sa_token", + return_value="sa-jwt-from-file", + ) + def test_feast_env_takes_priority_over_sa_token(self, _mock_sa): + mgr = self._make_manager() + assert mgr.get_token() == "env-token" + _mock_sa.assert_not_called() + + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OidcAuthClientManager._read_sa_token", + return_value="sa-jwt-from-file", + ) + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) + def test_explicit_token_skips_sa_file(self, mock_discovery_cls, _mock_sa): + mgr = self._make_manager(token="my-explicit-token") + assert mgr.get_token() == "my-explicit-token" + _mock_sa.assert_not_called() + + @patch.dict(os.environ, {}, clear=False) + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OidcAuthClientManager._read_sa_token", + return_value="sa-jwt-from-file", + ) + @patch("feast.permissions.client.oidc_authentication_client_manager.requests") + @patch( + "feast.permissions.client.oidc_authentication_client_manager.OIDCDiscoveryService" + ) + def test_client_secret_skips_sa_file( + self, mock_discovery_cls, mock_requests, _mock_sa + ): + os.environ.pop("FEAST_OIDC_TOKEN", None) + + mock_discovery_instance = MagicMock() + mock_discovery_instance.get_token_url.return_value = "https://idp/token" + mock_discovery_cls.return_value = mock_discovery_instance + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "network-token"} + mock_requests.post.return_value = mock_response + + mgr = self._make_manager( + client_secret="secret", # pragma: allowlist secret + auth_discovery_url="https://idp/.well-known/openid-configuration", + client_id="feast-client", + ) + assert mgr.get_token() == "network-token" + _mock_sa.assert_not_called() + # --------------------------------------------------------------------------- # Routing (RepoConfig.auth_config property) @@ -335,3 +415,17 @@ def test_ropg_routes_to_client(self): } ) assert isinstance(rc.auth_config, OidcClientAuthConfig) + + def test_incomplete_ropg_routes_to_client_with_actionable_error(self): + with pytest.raises( + ValueError, match="Incomplete configuration for 'client_credentials'" + ): + self._make_repo_config( + { + "type": "oidc", + "auth_discovery_url": "https://idp/.well-known/openid-configuration", + "client_id": "feast-client", + "username": "user1", + "password": "pass1", # pragma: allowlist secret + } + ).auth_config From 86c9d76a44fb25c914f483bcd5353b6e1c528e8e Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Thu, 12 Mar 2026 00:29:24 +0530 Subject: [PATCH 14/39] Improved code readibility Signed-off-by: Aniket Paluskar --- .../permissions/auth/oidc_token_parser.py | 87 ++++++------------- .../permissions/auth/test_token_parser.py | 45 +--------- 2 files changed, 28 insertions(+), 104 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 6f3147c177e..ccaa840def8 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -69,34 +69,20 @@ def _extract_username_or_raise_error(data: dict) -> str: ) return data["preferred_username"] - def _extract_roles(self, data: dict) -> list[str]: - """Extract client-scoped roles from `resource_access..roles`.""" - if "resource_access" not in data: - logger.warning("Missing resource_access field in access token.") - return [] - client_id = self._auth_config.client_id - if client_id not in data["resource_access"]: - logger.warning( - f"Missing resource_access.{client_id} field in access token. Defaulting to empty roles." - ) - return [] - client_entry = data["resource_access"][client_id] - if "roles" not in client_entry: - logger.warning( - f"Missing resource_access.{client_id}.roles field in access token. Defaulting to empty roles." - ) - return [] - return client_entry["roles"] - @staticmethod - def _extract_claim(data: dict, claim: str) -> list[str]: - """Extract an optional list-of-strings claim. Returns [] if missing.""" - if claim not in data: - logger.debug( - f"Missing {claim} field in access token. Defaulting to empty {claim}." - ) - return [] - return data[claim] + def _extract_claim(data: dict, *keys: str, expected_type: type = list): + """Walk *keys* into *data* and return the leaf value, or ``expected_type()`` if any key is missing or the wrong type.""" + node = data + path = ".".join(keys) + for key in keys: + if not isinstance(node, dict) or key not in node: + logger.warning(f"Missing {key} in access token claim path '{path}'. Defaulting to {expected_type()}.") + return expected_type() + node = node[key] + if not isinstance(node, expected_type): + logger.warning(f"Expected {expected_type.__name__} at '{path}', got {type(node).__name__}. Defaulting to {expected_type()}.") + return expected_type() + return node def _decode_token(self, access_token: str) -> dict: """Fetch the JWKS signing key and decode + verify the JWT.""" @@ -118,45 +104,29 @@ def _decode_token(self, access_token: str) -> dict: leeway=10, # accepts tokens generated up to 10 seconds in the past, in case of clock skew ) - async def _try_delegate_to_k8s_parser( - self, access_token: str - ) -> Optional[User]: - """Detect K8s service-account tokens and delegate to the K8s parser. - - Returns a ``User`` if the token was handled, or ``None`` if it should - continue through the standard OIDC path. - """ - if self._k8s_parser is None: - return None - + @staticmethod + def _is_kubernetes_token(access_token: str) -> bool: + """Check if the token contains the ``kubernetes.io`` claim.""" try: unverified = jwt.decode( access_token, options={"verify_signature": False} ) except jwt.exceptions.DecodeError as e: raise AuthenticationError(f"Failed to decode token: {e}") - - if "kubernetes.io" not in unverified: - return None - - logger.debug( - "Detected kubernetes.io claim — delegating to KubernetesTokenParser" - ) - return await self._k8s_parser.user_details_from_access_token( - access_token - ) + return "kubernetes.io" in unverified async def user_details_from_access_token(self, access_token: str) -> User: """ Validate the access token then decode it to extract the user credentials, - roles, groups, and namespaces. + roles, and groups. Kubernetes service-account tokens (identified by the ``kubernetes.io`` - claim) are delegated to the K8s parser when available. All other tokens - follow the standard Keycloak JWKS validation path. + claim) are delegated to the K8s parser when available (namespaces are + extracted there, not here — Keycloak JWTs don't carry namespace claims). + All other tokens follow the standard Keycloak JWKS validation path. Returns: - User: Current user, with associated roles, groups, and namespaces. + User: Current user, with associated roles, groups, or namespaces. Raises: AuthenticationError if any error happens. @@ -167,10 +137,9 @@ async def user_details_from_access_token(self, access_token: str) -> User: if user: return user - # Detect K8s service-account tokens and delegate - user = await self._try_delegate_to_k8s_parser(access_token) - if user: - return user + if self._k8s_parser and self._is_kubernetes_token(access_token): + logger.debug("Detected kubernetes.io claim — delegating to KubernetesTokenParser") + return await self._k8s_parser.user_details_from_access_token(access_token) # Standard OIDC / Keycloak flow try: @@ -184,18 +153,16 @@ async def user_details_from_access_token(self, access_token: str) -> User: data = self._decode_token(access_token) current_user = self._extract_username_or_raise_error(data) - roles = self._extract_roles(data) + roles = self._extract_claim(data, "resource_access", self._auth_config.client_id, "roles") groups = self._extract_claim(data, "groups") - namespaces = self._extract_claim(data, "namespace") logger.info( - f"Extracted user {current_user} with roles {roles}, groups {groups}, namespaces {namespaces}" + f"Extracted user {current_user} with roles {roles}, groups {groups}" ) return User( username=current_user, roles=roles, groups=groups, - namespaces=namespaces, ) except jwt.exceptions.InvalidTokenError: logger.exception("Exception while parsing the token:") diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 9b0622a3ba8..8150b9a3250 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -145,47 +145,7 @@ def test_oidc_token_extracts_groups( @patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") @patch("feast.permissions.auth.oidc_token_parser.jwt.decode") @patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") -def test_oidc_token_extracts_namespaces( - mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config -): - signing_key = MagicMock() - signing_key.key = "a-key" - mock_signing_key.return_value = signing_key - - mock_discovery_data.return_value = { - "authorization_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/auth", - "token_endpoint": "https://localhost:8080/realms/master/protocol/openid-connect/token", - "jwks_uri": "https://localhost:8080/realms/master/protocol/openid-connect/certs", - } - - user_data = { - "preferred_username": "my-name", - "resource_access": {_CLIENT_ID: {"roles": ["reader"]}}, - "namespaces": ["production", "staging"], - } - mock_jwt.return_value = user_data - - access_token = "aaa-bbb-ccc" - token_parser = OidcTokenParser(auth_config=oidc_config) - user = asyncio.run( - token_parser.user_details_from_access_token(access_token=access_token) - ) - - assertpy.assert_that(user).is_type_of(User) - if isinstance(user, User): - assertpy.assert_that(user.namespaces).is_equal_to(["production", "staging"]) - assertpy.assert_that(user.has_matching_namespace(["production"])).is_true() - assertpy.assert_that(user.has_matching_namespace(["unknown-ns"])).is_false() - assertpy.assert_that(user.groups).is_equal_to([]) - - -@patch( - "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" -) -@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") -@patch("feast.permissions.auth.oidc_token_parser.jwt.decode") -@patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") -def test_oidc_token_extracts_groups_and_namespaces( +def test_oidc_token_extracts_groups_and_roles( mock_discovery_data, mock_jwt, mock_signing_key, mock_oauth2, oidc_config ): signing_key = MagicMock() @@ -202,7 +162,6 @@ def test_oidc_token_extracts_groups_and_namespaces( "preferred_username": "my-name", "resource_access": {_CLIENT_ID: {"roles": ["reader", "writer"]}}, "groups": ["banking-admin", "data-engineers"], - "namespaces": ["production", "staging"], } mock_jwt.return_value = user_data @@ -221,10 +180,8 @@ def test_oidc_token_extracts_groups_and_namespaces( assertpy.assert_that(user.groups).is_equal_to( ["banking-admin", "data-engineers"] ) - assertpy.assert_that(user.namespaces).is_equal_to(["production", "staging"]) assertpy.assert_that(user.has_matching_role(["reader"])).is_true() assertpy.assert_that(user.has_matching_group(["banking-admin"])).is_true() - assertpy.assert_that(user.has_matching_namespace(["production"])).is_true() @patch( From 5621f079168001048d77220f51a39698afd55125 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Thu, 12 Mar 2026 01:03:49 +0530 Subject: [PATCH 15/39] Minor reformatting Signed-off-by: Aniket Paluskar --- .../permissions/auth/oidc_token_parser.py | 20 ++++++++++++------- sdk/python/feast/permissions/server/utils.py | 4 +--- sdk/python/feast/repo_config.py | 4 +++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index ccaa840def8..0559f02e3c5 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -76,11 +76,15 @@ def _extract_claim(data: dict, *keys: str, expected_type: type = list): path = ".".join(keys) for key in keys: if not isinstance(node, dict) or key not in node: - logger.warning(f"Missing {key} in access token claim path '{path}'. Defaulting to {expected_type()}.") + logger.warning( + f"Missing {key} in access token claim path '{path}'. Defaulting to {expected_type()}." + ) return expected_type() node = node[key] if not isinstance(node, expected_type): - logger.warning(f"Expected {expected_type.__name__} at '{path}', got {type(node).__name__}. Defaulting to {expected_type()}.") + logger.warning( + f"Expected {expected_type.__name__} at '{path}', got {type(node).__name__}. Defaulting to {expected_type()}." + ) return expected_type() return node @@ -108,9 +112,7 @@ def _decode_token(self, access_token: str) -> dict: def _is_kubernetes_token(access_token: str) -> bool: """Check if the token contains the ``kubernetes.io`` claim.""" try: - unverified = jwt.decode( - access_token, options={"verify_signature": False} - ) + unverified = jwt.decode(access_token, options={"verify_signature": False}) except jwt.exceptions.DecodeError as e: raise AuthenticationError(f"Failed to decode token: {e}") return "kubernetes.io" in unverified @@ -138,7 +140,9 @@ async def user_details_from_access_token(self, access_token: str) -> User: return user if self._k8s_parser and self._is_kubernetes_token(access_token): - logger.debug("Detected kubernetes.io claim — delegating to KubernetesTokenParser") + logger.debug( + "Detected kubernetes.io claim — delegating to KubernetesTokenParser" + ) return await self._k8s_parser.user_details_from_access_token(access_token) # Standard OIDC / Keycloak flow @@ -153,7 +157,9 @@ async def user_details_from_access_token(self, access_token: str) -> User: data = self._decode_token(access_token) current_user = self._extract_username_or_raise_error(data) - roles = self._extract_claim(data, "resource_access", self._auth_config.client_id, "roles") + roles = self._extract_claim( + data, "resource_access", self._auth_config.client_id, "roles" + ) groups = self._extract_claim(data, "groups") logger.info( diff --git a/sdk/python/feast/permissions/server/utils.py b/sdk/python/feast/permissions/server/utils.py index f76e48ab9fb..9e22a908f36 100644 --- a/sdk/python/feast/permissions/server/utils.py +++ b/sdk/python/feast/permissions/server/utils.py @@ -132,9 +132,7 @@ def init_auth_manager( "K8s API available — SA token support enabled for OIDC auth" ) except Exception as e: - logger.info( - f"K8s API unavailable ({e}) — OIDC-only token parsing" - ) + logger.info(f"K8s API unavailable ({e}) — OIDC-only token parsing") token_parser = OidcTokenParser( auth_config=auth_config, k8s_parser=k8s_parser ) diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 77aeeb5a123..75073018737 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -120,7 +120,9 @@ "oidc_client": "feast.permissions.auth_model.OidcClientAuthConfig", } -_OIDC_CLIENT_KEYS = frozenset({"client_secret", "token", "token_env_var", "username", "password"}) +_OIDC_CLIENT_KEYS = frozenset( + {"client_secret", "token", "token_env_var", "username", "password"} +) def _is_oidc_client_config(auth_dict: dict) -> bool: From b5db1571cab9d690eac034d787d61f298595703c Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Thu, 12 Mar 2026 12:15:10 +0530 Subject: [PATCH 16/39] fix: Use exact dict-key lookup for kubernetes.io claim to satisfy CodeQL substring sanitization check Signed-off-by: Aniket Paluskar --- sdk/python/feast/permissions/auth/oidc_token_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 0559f02e3c5..bad6b3e1d7d 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -110,12 +110,12 @@ def _decode_token(self, access_token: str) -> dict: @staticmethod def _is_kubernetes_token(access_token: str) -> bool: - """Check if the token contains the ``kubernetes.io`` claim.""" + """Check if the token contains the ``kubernetes.io`` claim (a dict with namespace, pod, serviceaccount).""" try: unverified = jwt.decode(access_token, options={"verify_signature": False}) except jwt.exceptions.DecodeError as e: raise AuthenticationError(f"Failed to decode token: {e}") - return "kubernetes.io" in unverified + return isinstance(unverified.get("kubernetes.io"), dict) async def user_details_from_access_token(self, access_token: str) -> User: """ From 13310576492133297095de49b51a7d57afa80d28 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Fri, 20 Mar 2026 18:26:45 +0530 Subject: [PATCH 17/39] feat: Add verify_ssl support to OIDC auth flow for self-signed certificates Signed-off-by: Aniket Paluskar Made-with: Cursor --- .../feast/permissions/auth/oidc_token_parser.py | 12 ++++++++++-- sdk/python/feast/permissions/auth_model.py | 1 + .../client/oidc_authentication_client_manager.py | 8 ++++++-- sdk/python/feast/permissions/oidc_service.py | 5 +++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index bad6b3e1d7d..1200e36bc90 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -1,5 +1,6 @@ import logging import os +import ssl from typing import Optional from unittest.mock import Mock @@ -40,7 +41,8 @@ def __init__( self._auth_config = auth_config self._k8s_parser = k8s_parser self.oidc_discovery_service = OIDCDiscoveryService( - self._auth_config.auth_discovery_url + self._auth_config.auth_discovery_url, + verify_ssl=self._auth_config.verify_ssl, ) async def _validate_token(self, access_token: str): @@ -91,8 +93,14 @@ def _extract_claim(data: dict, *keys: str, expected_type: type = list): def _decode_token(self, access_token: str) -> dict: """Fetch the JWKS signing key and decode + verify the JWT.""" optional_custom_headers = {"User-agent": "custom-user-agent"} + ssl_ctx = ssl.create_default_context() + if not self._auth_config.verify_ssl: + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE jwks_client = PyJWKClient( - self.oidc_discovery_service.get_jwks_url(), headers=optional_custom_headers + self.oidc_discovery_service.get_jwks_url(), + headers=optional_custom_headers, + ssl_context=ssl_ctx, ) signing_key = jwks_client.get_signing_key_from_jwt(access_token) return jwt.decode( diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index c20379d82a5..c1f49921b83 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -36,6 +36,7 @@ class AuthConfig(FeastConfigBaseModel): class OidcAuthConfig(AuthConfig): auth_discovery_url: str client_id: str + verify_ssl: bool = True class OidcClientAuthConfig(OidcAuthConfig): diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index 7def2288702..4676db385d4 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -69,7 +69,8 @@ def _fetch_token_from_idp(self) -> str: """Obtain an access token via client_credentials or ROPG flow.""" assert self.auth_config.auth_discovery_url is not None token_endpoint = OIDCDiscoveryService( - self.auth_config.auth_discovery_url + self.auth_config.auth_discovery_url, + verify_ssl=self.auth_config.verify_ssl, ).get_token_url() if self.auth_config.client_secret and not ( @@ -91,7 +92,10 @@ def _fetch_token_from_idp(self) -> str: headers = {"Content-Type": "application/x-www-form-urlencoded"} token_response = requests.post( - token_endpoint, data=token_request_body, headers=headers + token_endpoint, + data=token_request_body, + headers=headers, + verify=self.auth_config.verify_ssl, ) if token_response.status_code == 200: diff --git a/sdk/python/feast/permissions/oidc_service.py b/sdk/python/feast/permissions/oidc_service.py index 73d0ec8f1b7..37a9507102e 100644 --- a/sdk/python/feast/permissions/oidc_service.py +++ b/sdk/python/feast/permissions/oidc_service.py @@ -2,8 +2,9 @@ class OIDCDiscoveryService: - def __init__(self, discovery_url: str): + def __init__(self, discovery_url: str, verify_ssl: bool = True): self.discovery_url = discovery_url + self._verify_ssl = verify_ssl self._discovery_data = None # Initialize it lazily. @property @@ -15,7 +16,7 @@ def discovery_data(self): def _fetch_discovery_data(self) -> dict: try: - response = requests.get(self.discovery_url) + response = requests.get(self.discovery_url, verify=self._verify_ssl) response.raise_for_status() return response.json() except requests.RequestException as e: From a87516960d004557f295861b0c22f5b98970bec5 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Mon, 23 Mar 2026 20:56:19 +0530 Subject: [PATCH 18/39] =?UTF-8?q?feat:=20Lightweight=20SA=20token=20valida?= =?UTF-8?q?tion=20for=20OIDC=20auth=20=E2=80=94=20TokenReview=20only,=20no?= =?UTF-8?q?=20RBAC=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace KubernetesTokenParser delegation with a lightweight _validate_k8s_sa_token_and_extract_namespace() method in OidcTokenParser. Validates SA tokens via TokenReview API and extracts namespace from the authenticated identity. No RoleBinding/ClusterRoleBinding queries needed, so the server SA only requires tokenreviews/create permission. Also updates OIDC auth documentation with token priority, verify_ssl, groups claim, and multi-token support sections. Made-with: Cursor Signed-off-by: Aniket Paluskar --- .../components/authz_manager.md | 69 +++++++++++++++---- .../permissions/auth/oidc_token_parser.py | 52 ++++++++++---- sdk/python/feast/permissions/server/utils.py | 16 +---- .../permissions/auth/server/mock_utils.py | 4 +- .../permissions/auth/test_token_parser.py | 55 +++++++-------- 5 files changed, 124 insertions(+), 72 deletions(-) diff --git a/docs/getting-started/components/authz_manager.md b/docs/getting-started/components/authz_manager.md index e5aa0661619..eae3fece50b 100644 --- a/docs/getting-started/components/authz_manager.md +++ b/docs/getting-started/components/authz_manager.md @@ -40,52 +40,87 @@ auth: With OIDC authorization, the Feast client proxies retrieve the JWT token from an OIDC server (or [Identity Provider](https://openid.net/developers/how-connect-works/)) and append it in every request to a Feast server, using an [Authorization Bearer Token](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#bearer). -The server, in turn, uses the same OIDC server to validate the token and extract the user roles from the token itself. +The server, in turn, uses the same OIDC server to validate the token and extract user details — including username, roles, and groups — from the token itself. Some assumptions are made in the OIDC server configuration: * The OIDC token refers to a client with roles matching the RBAC roles of the configured `Permission`s (*) -* The roles are exposed in the access token that is passed to the server +* The roles are exposed in the access token under `resource_access..roles` * The JWT token is expected to have a verified signature and not be expired. The Feast OIDC token parser logic validates for `verify_signature` and `verify_exp` so make sure that the given OIDC provider is configured to meet these requirements. -* The preferred_username should be part of the JWT token claim. - +* The `preferred_username` should be part of the JWT token claim. +* For `GroupBasedPolicy` support, the `groups` claim should be present in the access token (requires a "Group Membership" protocol mapper in Keycloak). (*) Please note that **the role match is case-sensitive**, e.g. the name of the role in the OIDC server and in the `Permission` configuration must be exactly the same. -For example, the access token for a client `app` of a user with `reader` role should have the following `resource_access` section: +For example, the access token for a client `app` of a user with `reader` role and membership in the `data-team` group should have the following claims: ```json { + "preferred_username": "alice", "resource_access": { "app": { "roles": [ "reader" ] } - } + }, + "groups": [ + "data-team" + ] } ``` -An example of feast OIDC authorization configuration on the server side is the following: +#### Server-Side Configuration + +The server requires `auth_discovery_url` and `client_id` to validate incoming JWT tokens via JWKS: ```yaml project: my-project auth: type: oidc - client_id: _CLIENT_ID__ + client_id: _CLIENT_ID_ auth_discovery_url: _OIDC_SERVER_URL_/realms/master/.well-known/openid-configuration ... ``` -In case of client configuration, the following settings username, password and client_secret must be added to specify the current user: +When the OIDC provider uses a self-signed or untrusted TLS certificate (e.g. internal Keycloak on OpenShift), set `verify_ssl` to `false` to disable certificate verification: +```yaml +auth: + type: oidc + client_id: _CLIENT_ID_ + auth_discovery_url: https://keycloak.internal/realms/master/.well-known/openid-configuration + verify_ssl: false +``` + +{% hint style="warning" %} +Setting `verify_ssl: false` disables TLS certificate verification for all OIDC provider communication (discovery, JWKS, token endpoint). Only use this in development or internal environments where you accept the security risk. +{% endhint %} + +#### Client-Side Configuration + +The client supports multiple token source modes. The SDK resolves tokens in the following priority order: + +1. **Intra-communication token** — internal server-to-server calls (via `INTRA_COMMUNICATION_BASE64` env var) +2. **`token`** — a static JWT string provided directly in the configuration +3. **`token_env_var`** — the name of an environment variable containing the JWT +4. **`client_secret`** — fetches a token from the OIDC provider using client credentials or ROPC flow (requires `auth_discovery_url` and `client_id`) +5. **`FEAST_OIDC_TOKEN`** — default fallback environment variable +6. **Kubernetes service account token** — read from `/var/run/secrets/kubernetes.io/serviceaccount/token` when running inside a pod + +**Token passthrough** (for use with external token providers like [kube-authkit](https://github.com/opendatahub-io/kube-authkit)): +```yaml +project: my-project +auth: + type: oidc + token_env_var: FEAST_OIDC_TOKEN +``` + +Or with a bare `type: oidc` (no other fields) — the SDK falls back to the `FEAST_OIDC_TOKEN` environment variable or a mounted Kubernetes service account token: ```yaml +project: my-project auth: type: oidc - ... - username: _USERNAME_ - password: _PASSWORD_ - client_secret: _CLIENT_SECRET__ ``` -Below is an example of feast full OIDC client auth configuration: +**Client credentials / ROPC flow** (existing behavior, unchanged): ```yaml project: my-project auth: @@ -97,6 +132,12 @@ auth: auth_discovery_url: http://localhost:8080/realms/master/.well-known/openid-configuration ``` +When using client credentials or ROPC flows, the `verify_ssl` setting also applies to the discovery and token endpoint requests. + +#### Multi-Token Support (OIDC + Kubernetes Service Account) + +When the Feast server is configured with OIDC auth and deployed on Kubernetes, the `OidcTokenParser` can handle both Keycloak JWT tokens and Kubernetes service account tokens. Incoming tokens that contain a `kubernetes.io` claim are validated via the Kubernetes Token Access Review API and the namespace is extracted from the authenticated identity — no RBAC queries are performed, so the server service account only needs `tokenreviews/create` permission. All other tokens follow the standard OIDC/Keycloak JWKS validation path. This enables `NamespaceBasedPolicy` enforcement for service account tokens while using `GroupBasedPolicy` and `RoleBasedPolicy` for OIDC user tokens. + ### Kubernetes RBAC Authorization With Kubernetes RBAC Authorization, the client uses the service account token as the authorizarion bearer token, and the server fetches the associated roles from the Kubernetes RBAC resources. Feast supports advanced authorization by extracting user groups and namespaces from Kubernetes tokens, enabling fine-grained access control beyond simple role matching. This is achieved by leveraging Kubernetes Token Access Review, which allows Feast to determine the groups and namespaces associated with a user or service account. diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 1200e36bc90..862c0fff5d9 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -11,6 +11,7 @@ from starlette.authentication import ( AuthenticationError, ) +from kubernetes import client, config from feast.permissions.auth.token_parser import TokenParser from feast.permissions.auth_model import OidcAuthConfig @@ -25,21 +26,16 @@ class OidcTokenParser(TokenParser): A ``TokenParser`` to use an OIDC server to retrieve the user details. Server settings are retrieved from the ``auth`` configuration of the Feature store. - When running inside Kubernetes, an optional ``k8s_parser`` can be supplied. Incoming tokens that contain a ``kubernetes.io`` claim (i.e. Kubernetes - service-account tokens) are delegated to the K8s parser, while all other - tokens follow the standard OIDC/Keycloak JWKS validation path. + service-account tokens) are handled via a lightweight TokenReview that + extracts only the namespace — no RBAC queries needed. All other tokens + follow the standard OIDC/Keycloak JWKS validation path. """ _auth_config: OidcAuthConfig - def __init__( - self, - auth_config: OidcAuthConfig, - k8s_parser: Optional[TokenParser] = None, - ): + def __init__(self, auth_config: OidcAuthConfig): self._auth_config = auth_config - self._k8s_parser = k8s_parser self.oidc_discovery_service = OIDCDiscoveryService( self._auth_config.auth_discovery_url, verify_ssl=self._auth_config.verify_ssl, @@ -147,11 +143,11 @@ async def user_details_from_access_token(self, access_token: str) -> User: if user: return user - if self._k8s_parser and self._is_kubernetes_token(access_token): + if self._is_kubernetes_token(access_token): logger.debug( - "Detected kubernetes.io claim — delegating to KubernetesTokenParser" + "Detected kubernetes.io claim — validating via TokenReview" ) - return await self._k8s_parser.user_details_from_access_token(access_token) + return await self._validate_k8s_sa_token_and_extract_namespace(access_token) # Standard OIDC / Keycloak flow try: @@ -182,6 +178,38 @@ async def user_details_from_access_token(self, access_token: str) -> User: logger.exception("Exception while parsing the token:") raise AuthenticationError("Invalid token.") + @staticmethod + async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> User: + """Validate a K8s SA token via TokenReview and extract the namespace. + + Lightweight alternative to full KubernetesTokenParser — only validates + the token and extracts the namespace from the authenticated identity. + No RBAC queries (RoleBindings, ClusterRoleBindings) are performed, + so the server SA needs only ``tokenreviews/create`` permission. + """ + config.load_incluster_config() + auth_v1 = client.AuthenticationV1Api() + + token_review = client.V1TokenReview( + spec=client.V1TokenReviewSpec(token=access_token) + ) + response = auth_v1.create_token_review(token_review) + + if not response.status.authenticated: + raise AuthenticationError( + f"Kubernetes token validation failed: {response.status.error}" + ) + + username = getattr(response.status.user, "username", "") or "" + namespaces = [] + if username.startswith("system:serviceaccount:") and username.count(":") >= 3: + namespaces.append(username.split(":")[2]) + + logger.info( + f"SA token validated — user: {username}, namespaces: {namespaces}" + ) + return User(username=username, roles=[], groups=[], namespaces=namespaces) + def _get_intra_comm_user(self, access_token: str) -> Optional[User]: intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") diff --git a/sdk/python/feast/permissions/server/utils.py b/sdk/python/feast/permissions/server/utils.py index 9e22a908f36..cd72ae58204 100644 --- a/sdk/python/feast/permissions/server/utils.py +++ b/sdk/python/feast/permissions/server/utils.py @@ -121,21 +121,7 @@ def init_auth_manager( token_parser = KubernetesTokenParser() elif auth_type == AuthManagerType.OIDC: assert isinstance(auth_config, OidcAuthConfig) - k8s_parser = None - try: - from feast.permissions.auth.kubernetes_token_parser import ( - KubernetesTokenParser, - ) - - k8s_parser = KubernetesTokenParser() - logger.info( - "K8s API available — SA token support enabled for OIDC auth" - ) - except Exception as e: - logger.info(f"K8s API unavailable ({e}) — OIDC-only token parsing") - token_parser = OidcTokenParser( - auth_config=auth_config, k8s_parser=k8s_parser - ) + token_parser = OidcTokenParser(auth_config=auth_config) else: raise ValueError(f"Unmanaged authorization manager type {auth_type}") diff --git a/sdk/python/tests/unit/permissions/auth/server/mock_utils.py b/sdk/python/tests/unit/permissions/auth/server/mock_utils.py index 5bde4d4ecbc..4d62bbf4348 100644 --- a/sdk/python/tests/unit/permissions/auth/server/mock_utils.py +++ b/sdk/python/tests/unit/permissions/auth/server/mock_utils.py @@ -32,14 +32,14 @@ async def mock_oath2(self, request): } monkeypatch.setattr( "feast.permissions.client.oidc_authentication_client_manager.requests.get", - lambda url: discovery_response, + lambda url, verify=True: discovery_response, ) token_response = Mock(spec=Response) token_response.status_code = 200 token_response.json.return_value = {"access_token": "my-token"} monkeypatch.setattr( "feast.permissions.client.oidc_authentication_client_manager.requests.post", - lambda url, data, headers: token_response, + lambda url, data, headers, verify=True: token_response, ) monkeypatch.setattr( diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index 8150b9a3250..c34981ac310 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -1,7 +1,7 @@ import asyncio import os from unittest import mock -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import assertpy import pytest @@ -398,48 +398,48 @@ def test_k8s_inter_server_comm( # --------------------------------------------------------------------------- -@patch( - "feast.permissions.auth.oidc_token_parser.OAuth2AuthorizationCodeBearer.__call__" -) -@patch("feast.permissions.auth.oidc_token_parser.PyJWKClient.get_signing_key_from_jwt") @patch("feast.permissions.auth.oidc_token_parser.jwt.decode") @patch("feast.permissions.oidc_service.OIDCDiscoveryService._fetch_discovery_data") -def test_oidc_parser_routes_sa_token_to_k8s_parser( - mock_discovery_data, mock_jwt_decode, mock_signing_key, mock_oauth2, oidc_config +def test_oidc_parser_handles_sa_token_via_token_review( + mock_discovery_data, mock_jwt_decode, oidc_config ): - """When a token contains kubernetes.io claim, it should be routed to the K8s parser.""" + """When a token contains kubernetes.io claim, _handle_sa_token is called (not the OIDC JWKS path).""" mock_discovery_data.return_value = { "authorization_endpoint": "https://localhost:8080/auth", "token_endpoint": "https://localhost:8080/token", "jwks_uri": "https://localhost:8080/certs", } + mock_jwt_decode.return_value = { + "kubernetes.io": {"namespace": "feast"}, + "sub": "system:serviceaccount:feast:feast", + } + sa_user = User( - username="feast:feast", + username="system:serviceaccount:feast:feast", roles=[], - groups=["system:serviceaccounts:feast"], + groups=[], namespaces=["feast"], ) - k8s_parser = MagicMock() - k8s_parser.user_details_from_access_token = AsyncMock(return_value=sa_user) + token_parser = OidcTokenParser(auth_config=oidc_config) - # jwt.decode is patched globally — the unverified decode inside the parser - # returns a payload with kubernetes.io claim - mock_jwt_decode.return_value = { - "kubernetes.io": {"namespace": "feast"}, - "sub": "system:serviceaccount:feast:feast", - } + with patch.object( + token_parser, + "_validate_k8s_sa_token_and_extract_namespace", + return_value=sa_user, + ) as mock_handle: + user = asyncio.run( + token_parser.user_details_from_access_token(access_token="sa-token") + ) + mock_handle.assert_called_once_with("sa-token") - token_parser = OidcTokenParser(auth_config=oidc_config, k8s_parser=k8s_parser) - user = asyncio.run( - token_parser.user_details_from_access_token(access_token="sa-token") + assertpy.assert_that(user.username).is_equal_to( + "system:serviceaccount:feast:feast" ) - - k8s_parser.user_details_from_access_token.assert_called_once_with("sa-token") - assertpy.assert_that(user.username).is_equal_to("feast:feast") assertpy.assert_that(user.namespaces).is_equal_to(["feast"]) - mock_signing_key.assert_not_called() + assertpy.assert_that(user.roles).is_equal_to([]) + assertpy.assert_that(user.groups).is_equal_to([]) @patch( @@ -469,14 +469,11 @@ def test_oidc_parser_routes_keycloak_token_normally( } mock_jwt_decode.return_value = keycloak_payload - k8s_parser = MagicMock() - - token_parser = OidcTokenParser(auth_config=oidc_config, k8s_parser=k8s_parser) + token_parser = OidcTokenParser(auth_config=oidc_config) user = asyncio.run( token_parser.user_details_from_access_token(access_token="keycloak-jwt") ) - k8s_parser.user_details_from_access_token.assert_not_called() assertpy.assert_that(user.username).is_equal_to("testuser") assertpy.assert_that(user.roles).is_equal_to(["reader"]) assertpy.assert_that(user.groups).is_equal_to(["data-team"]) From 7e59a53e43c63d3454c51d2db14e0962c122c916 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Mon, 23 Mar 2026 21:20:25 +0530 Subject: [PATCH 19/39] Minor reformatting & lint related changes Signed-off-by: Aniket Paluskar --- .../feast/permissions/auth/oidc_token_parser.py | 11 ++++------- .../tests/unit/permissions/auth/test_token_parser.py | 4 +--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 862c0fff5d9..5ab0d5a442a 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -11,7 +11,6 @@ from starlette.authentication import ( AuthenticationError, ) -from kubernetes import client, config from feast.permissions.auth.token_parser import TokenParser from feast.permissions.auth_model import OidcAuthConfig @@ -144,9 +143,7 @@ async def user_details_from_access_token(self, access_token: str) -> User: return user if self._is_kubernetes_token(access_token): - logger.debug( - "Detected kubernetes.io claim — validating via TokenReview" - ) + logger.debug("Detected kubernetes.io claim — validating via TokenReview") return await self._validate_k8s_sa_token_and_extract_namespace(access_token) # Standard OIDC / Keycloak flow @@ -187,6 +184,8 @@ async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> Use No RBAC queries (RoleBindings, ClusterRoleBindings) are performed, so the server SA needs only ``tokenreviews/create`` permission. """ + from kubernetes import client, config + config.load_incluster_config() auth_v1 = client.AuthenticationV1Api() @@ -205,9 +204,7 @@ async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> Use if username.startswith("system:serviceaccount:") and username.count(":") >= 3: namespaces.append(username.split(":")[2]) - logger.info( - f"SA token validated — user: {username}, namespaces: {namespaces}" - ) + logger.info(f"SA token validated — user: {username}, namespaces: {namespaces}") return User(username=username, roles=[], groups=[], namespaces=namespaces) def _get_intra_comm_user(self, access_token: str) -> Optional[User]: diff --git a/sdk/python/tests/unit/permissions/auth/test_token_parser.py b/sdk/python/tests/unit/permissions/auth/test_token_parser.py index c34981ac310..fdec71e109a 100644 --- a/sdk/python/tests/unit/permissions/auth/test_token_parser.py +++ b/sdk/python/tests/unit/permissions/auth/test_token_parser.py @@ -434,9 +434,7 @@ def test_oidc_parser_handles_sa_token_via_token_review( ) mock_handle.assert_called_once_with("sa-token") - assertpy.assert_that(user.username).is_equal_to( - "system:serviceaccount:feast:feast" - ) + assertpy.assert_that(user.username).is_equal_to("system:serviceaccount:feast:feast") assertpy.assert_that(user.namespaces).is_equal_to(["feast"]) assertpy.assert_that(user.roles).is_equal_to([]) assertpy.assert_that(user.groups).is_equal_to([]) From 611607ba75843bd1e578a3493f307b32d8ae0447 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Mon, 23 Mar 2026 21:25:24 +0530 Subject: [PATCH 20/39] Update sdk/python/feast/permissions/auth/oidc_token_parser.py Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Signed-off-by: Aniket Paluskar --- .../feast/permissions/auth/oidc_token_parser.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 5ab0d5a442a..7353cf4e0dc 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -140,11 +140,17 @@ async def user_details_from_access_token(self, access_token: str) -> User: # check if intra server communication user = self._get_intra_comm_user(access_token) if user: - return user - if self._is_kubernetes_token(access_token): - logger.debug("Detected kubernetes.io claim — validating via TokenReview") - return await self._validate_k8s_sa_token_and_extract_namespace(access_token) + logger.debug( + "Detected kubernetes.io claim — validating via TokenReview" + ) + try: + return await self._validate_k8s_sa_token_and_extract_namespace(access_token) + except AuthenticationError: + raise + except Exception as e: + logger.error(f"Kubernetes token validation failed: {e}") + raise AuthenticationError(f"Kubernetes token validation failed: {e}") # Standard OIDC / Keycloak flow try: From 3c1e36bede5fff4dacd4fa187f3d536a756639f7 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Mon, 23 Mar 2026 21:36:06 +0530 Subject: [PATCH 21/39] fix: Restore missing return in intra-comm check and add error handling for K8s token validation Signed-off-by: Aniket Paluskar Made-with: Cursor --- sdk/python/feast/permissions/auth/oidc_token_parser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 7353cf4e0dc..5743554f183 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -140,6 +140,8 @@ async def user_details_from_access_token(self, access_token: str) -> User: # check if intra server communication user = self._get_intra_comm_user(access_token) if user: + return user + if self._is_kubernetes_token(access_token): logger.debug( "Detected kubernetes.io claim — validating via TokenReview" From c2c4863ebad15da585700663500d0736722f0b7f Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Mon, 23 Mar 2026 21:42:28 +0530 Subject: [PATCH 22/39] Minor reformatting Signed-off-by: Aniket Paluskar --- sdk/python/feast/permissions/auth/oidc_token_parser.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 5743554f183..264c88ad6ce 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -143,11 +143,11 @@ async def user_details_from_access_token(self, access_token: str) -> User: return user if self._is_kubernetes_token(access_token): - logger.debug( - "Detected kubernetes.io claim — validating via TokenReview" - ) + logger.debug("Detected kubernetes.io claim — validating via TokenReview") try: - return await self._validate_k8s_sa_token_and_extract_namespace(access_token) + return await self._validate_k8s_sa_token_and_extract_namespace( + access_token + ) except AuthenticationError: raise except Exception as e: From eed8b02491b4c7c2229fd85df8037f10a4663206 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 24 Mar 2026 01:17:59 +0530 Subject: [PATCH 23/39] Checks preferred_username first (Keycloak default), then falls back to upn (Azure AD / Entra ID) Signed-off-by: Aniket Paluskar --- .../permissions/auth/oidc_token_parser.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 264c88ad6ce..b42714ff98e 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -59,12 +59,18 @@ async def _validate_token(self, access_token: str): @staticmethod def _extract_username_or_raise_error(data: dict) -> str: - """Extract the username from the decoded JWT. Raises if missing — identity is mandatory.""" - if "preferred_username" not in data: - raise AuthenticationError( - "Missing preferred_username field in access token." - ) - return data["preferred_username"] + """Extract the username from the decoded JWT. Raises if missing — identity is mandatory. + + Checks ``preferred_username`` first (Keycloak default), then falls back + to ``upn`` (Azure AD / Entra ID). + """ + if "preferred_username" in data: + return data["preferred_username"] + if "upn" in data: + return data["upn"] + raise AuthenticationError( + "Missing preferred_username or upn field in access token." + ) @staticmethod def _extract_claim(data: dict, *keys: str, expected_type: type = list): From 593b95d894e4d083d434f1fdeeb6bcc5598dabb2 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 24 Mar 2026 13:42:36 +0530 Subject: [PATCH 24/39] feat(operator): Split server/client OIDC config and add secretKeyName, tokenEnvVar, verifySSL CRD fields Signed-off-by: Aniket Paluskar --- .../api/v1/featurestore_types.go | 15 ++++++ .../api/v1/zz_generated.deepcopy.go | 12 ++++- .../feast-operator.clusterserviceversion.yaml | 2 +- .../manifests/feast.dev_featurestores.yaml | 46 ++++++++++++++++++- .../crd/bases/feast.dev_featurestores.yaml | 26 +++++++++++ .../config/manager/kustomization.yaml | 4 +- infra/feast-operator/dist/install.yaml | 26 +++++++++++ infra/feast-operator/docs/api/markdown/ref.md | 9 ++++ .../featurestore_controller_oidc_auth_test.go | 6 --- .../controller/services/repo_config.go | 36 ++++++++------- .../controller/services/services_types.go | 7 +-- 11 files changed, 157 insertions(+), 32 deletions(-) diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index 9567a9de2b5..a8c2dc7faed 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -709,6 +709,21 @@ type KubernetesAuthz struct { // https://auth0.com/docs/authenticate/protocols/openid-connect-protocol type OidcAuthz struct { SecretRef corev1.LocalObjectReference `json:"secretRef"` + // The key within the Secret that contains the OIDC configuration as a YAML-encoded value. + // When set, only this key is read and its YAML value is expected to contain the OIDC properties + // (e.g. client_id, auth_discovery_url). This allows sharing a single Secret across services. + // When unset, each top-level key in the Secret is treated as a separate OIDC property. + // +optional + SecretKeyName string `json:"secretKeyName,omitempty"` + // The name of the environment variable that client pods will use to read a pre-existing OIDC token. + // When set, the client feature_store.yaml will include token_env_var with this value. + // When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. + // +optional + TokenEnvVar *string `json:"tokenEnvVar,omitempty"` + // Whether to verify SSL certificates when communicating with the OIDC provider. + // Defaults to true. Set to false for self-signed certificates (common in internal OpenShift clusters). + // +optional + VerifySSL *bool `json:"verifySSL,omitempty"` } // TlsConfigs configures server TLS for a feast service. in an openshift cluster, this is configured by default using service serving certificates. diff --git a/infra/feast-operator/api/v1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1/zz_generated.deepcopy.go index 87043017c71..3201787c5dd 100644 --- a/infra/feast-operator/api/v1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1/zz_generated.deepcopy.go @@ -41,7 +41,7 @@ func (in *AuthzConfig) DeepCopyInto(out *AuthzConfig) { if in.OidcAuthz != nil { in, out := &in.OidcAuthz, &out.OidcAuthz *out = new(OidcAuthz) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -745,6 +745,16 @@ func (in *OfflineStorePersistence) DeepCopy() *OfflineStorePersistence { func (in *OidcAuthz) DeepCopyInto(out *OidcAuthz) { *out = *in out.SecretRef = in.SecretRef + if in.TokenEnvVar != nil { + in, out := &in.TokenEnvVar, &out.TokenEnvVar + *out = new(string) + **out = **in + } + if in.VerifySSL != nil { + in, out := &in.VerifySSL, &out.VerifySSL + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OidcAuthz. diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index 64e6886444f..7278bd3faf5 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -50,7 +50,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-03-10T20:00:10Z" + createdAt: "2026-03-24T07:09:46Z" operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.61.0 diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 884b1291264..de351dda580 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -62,6 +62,10 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + secretKeyName: + description: The key within the Secret that contains the OIDC + configuration as a YAML-encoded value. + type: string secretRef: description: |- LocalObjectReference contains enough information to let you locate the @@ -76,6 +80,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + tokenEnvVar: + description: The name of the environment variable that client + pods will use to read a pre-existing OIDC token. + type: string + verifySSL: + description: |- + Whether to verify SSL certificates when communicating with the OIDC provider. + Defaults to true. + type: boolean required: - secretRef type: object @@ -3129,6 +3142,10 @@ spec: x-kubernetes-validations: - message: One selection required. rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + runFeastApplyOnInit: + description: Runs feast apply on pod start to populate the registry. + Defaults to true. Ignored when DisableInitContainers is true. + type: boolean scaling: description: Scaling configures horizontal scaling for the FeatureStore deployment (e.g. HPA autoscaling). @@ -5695,7 +5712,6 @@ spec: type: object required: - feastProject - - replicas type: object x-kubernetes-validations: - message: replicas > 1 and services.scaling.autoscaling are mutually @@ -5754,6 +5770,10 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + secretKeyName: + description: The key within the Secret that contains the + OIDC configuration as a YAML-encoded value. + type: string secretRef: description: |- LocalObjectReference contains enough information to let you locate the @@ -5768,6 +5788,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + tokenEnvVar: + description: The name of the environment variable that + client pods will use to read a pre-existing OIDC token. + type: string + verifySSL: + description: |- + Whether to verify SSL certificates when communicating with the OIDC provider. + Defaults to true. + type: boolean required: - secretRef type: object @@ -8871,6 +8900,11 @@ spec: - message: One selection required. rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + runFeastApplyOnInit: + description: Runs feast apply on pod start to populate the + registry. Defaults to true. Ignored when DisableInitContainers + is true. + type: boolean scaling: description: Scaling configures horizontal scaling for the FeatureStore deployment (e.g. HPA autoscaling). @@ -11458,7 +11492,6 @@ spec: type: object required: - feastProject - - replicas type: object x-kubernetes-validations: - message: replicas > 1 and services.scaling.autoscaling are mutually @@ -13920,6 +13953,10 @@ spec: x-kubernetes-validations: - message: One selection required. rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + runFeastApplyOnInit: + description: Runs feast apply on pod start to populate the registry. + Defaults to true. Ignored when DisableInitContainers is true. + type: boolean securityContext: description: PodSecurityContext holds pod-level security attributes and common container settings. @@ -18163,6 +18200,11 @@ spec: - message: One selection required. rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + runFeastApplyOnInit: + description: Runs feast apply on pod start to populate the + registry. Defaults to true. Ignored when DisableInitContainers + is true. + type: boolean securityContext: description: PodSecurityContext holds pod-level security attributes and common container settings. diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index cdf50015e0f..a0140b751cc 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -62,6 +62,10 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + secretKeyName: + description: The key within the Secret that contains the OIDC + configuration as a YAML-encoded value. + type: string secretRef: description: |- LocalObjectReference contains enough information to let you locate the @@ -76,6 +80,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + tokenEnvVar: + description: The name of the environment variable that client + pods will use to read a pre-existing OIDC token. + type: string + verifySSL: + description: |- + Whether to verify SSL certificates when communicating with the OIDC provider. + Defaults to true. + type: boolean required: - secretRef type: object @@ -5757,6 +5770,10 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + secretKeyName: + description: The key within the Secret that contains the + OIDC configuration as a YAML-encoded value. + type: string secretRef: description: |- LocalObjectReference contains enough information to let you locate the @@ -5771,6 +5788,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + tokenEnvVar: + description: The name of the environment variable that + client pods will use to read a pre-existing OIDC token. + type: string + verifySSL: + description: |- + Whether to verify SSL certificates when communicating with the OIDC provider. + Defaults to true. + type: boolean required: - secretRef type: object diff --git a/infra/feast-operator/config/manager/kustomization.yaml b/infra/feast-operator/config/manager/kustomization.yaml index 4a0a78531bb..ed438c33f39 100644 --- a/infra/feast-operator/config/manager/kustomization.yaml +++ b/infra/feast-operator/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: quay.io/feastdev/feast-operator - newTag: 0.61.0 + newName: quay.io/aniket-redhat/feast-operator + newTag: oidc-op-0.1 diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index c1818a54744..c68a6567159 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -70,6 +70,10 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + secretKeyName: + description: The key within the Secret that contains the OIDC + configuration as a YAML-encoded value. + type: string secretRef: description: |- LocalObjectReference contains enough information to let you locate the @@ -84,6 +88,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + tokenEnvVar: + description: The name of the environment variable that client + pods will use to read a pre-existing OIDC token. + type: string + verifySSL: + description: |- + Whether to verify SSL certificates when communicating with the OIDC provider. + Defaults to true. + type: boolean required: - secretRef type: object @@ -5765,6 +5778,10 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + secretKeyName: + description: The key within the Secret that contains the + OIDC configuration as a YAML-encoded value. + type: string secretRef: description: |- LocalObjectReference contains enough information to let you locate the @@ -5779,6 +5796,15 @@ spec: type: string type: object x-kubernetes-map-type: atomic + tokenEnvVar: + description: The name of the environment variable that + client pods will use to read a pre-existing OIDC token. + type: string + verifySSL: + description: |- + Whether to verify SSL certificates when communicating with the OIDC provider. + Defaults to true. + type: boolean required: - secretRef type: object diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index b7c7a1b8a71..9003a30de6f 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -543,6 +543,15 @@ _Appears in:_ | Field | Description | | --- | --- | | `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `secretKeyName` _string_ | The key within the Secret that contains the OIDC configuration as a YAML-encoded value. +When set, only this key is read and its YAML value is expected to contain the OIDC properties +(e.g. client_id, auth_discovery_url). This allows sharing a single Secret across services. +When unset, each top-level key in the Secret is treated as a separate OIDC property. | +| `tokenEnvVar` _string_ | The name of the environment variable that client pods will use to read a pre-existing OIDC token. +When set, the client feature_store.yaml will include token_env_var with this value. +When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. | +| `verifySSL` _boolean_ | Whether to verify SSL certificates when communicating with the OIDC provider. +Defaults to true. Set to false for self-signed certificates (common in internal OpenShift clusters). | #### OnlineStore 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 b8af9484acc..11c7b41c422 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 @@ -496,12 +496,6 @@ func expectedServerOidcAuthorizConfig() services.AuthzConfig { func expectedClientOidcAuthorizConfig() services.AuthzConfig { return services.AuthzConfig{ Type: services.OidcAuthType, - OidcParameters: map[string]interface{}{ - string(services.OidcClientId): "client-id", - string(services.OidcAuthDiscoveryUrl): "auth-discovery-url", - string(services.OidcClientSecret): "client-secret", - string(services.OidcUsername): "username", - string(services.OidcPassword): "password"}, } } diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 9b20955f324..a7c4fcd4de6 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -101,24 +101,34 @@ func getBaseServiceRepoConfig( if isRemoteRegistry(featureStore) { repoConfig.Registry = clientRepoConfig.Registry } - repoConfig.AuthzConfig = clientRepoConfig.AuthzConfig - appliedSpec := featureStore.Status.Applied if appliedSpec.AuthzConfig != nil && appliedSpec.AuthzConfig.OidcAuthz != nil { - propertiesMap, authSecretErr := secretExtractionFunc("", appliedSpec.AuthzConfig.OidcAuthz.SecretRef.Name, "") + repoConfig.AuthzConfig = AuthzConfig{Type: OidcAuthType} + + propertiesMap, authSecretErr := secretExtractionFunc("", appliedSpec.AuthzConfig.OidcAuthz.SecretRef.Name, appliedSpec.AuthzConfig.OidcAuthz.SecretKeyName) if authSecretErr != nil { return repoConfig, authSecretErr } oidcParameters := map[string]interface{}{} - for _, oidcProperty := range OidcProperties { + for _, oidcProperty := range OidcServerProperties { if val, exists := propertiesMap[string(oidcProperty)]; exists { oidcParameters[string(oidcProperty)] = val } else { return repoConfig, missingOidcSecretProperty(oidcProperty) } } + for _, oidcProperty := range OidcOptionalSecretProperties { + if val, exists := propertiesMap[string(oidcProperty)]; exists { + oidcParameters[string(oidcProperty)] = val + } + } + if appliedSpec.AuthzConfig.OidcAuthz.VerifySSL != nil { + oidcParameters[string(OidcVerifySsl)] = *appliedSpec.AuthzConfig.OidcAuthz.VerifySSL + } repoConfig.AuthzConfig.OidcParameters = oidcParameters + } else { + repoConfig.AuthzConfig = clientRepoConfig.AuthzConfig } return repoConfig, nil @@ -356,21 +366,13 @@ func getRepoConfig( repoConfig.AuthzConfig = AuthzConfig{ Type: OidcAuthType, } - - propertiesMap, err := secretExtractionFunc("", status.Applied.AuthzConfig.OidcAuthz.SecretRef.Name, "") - if err != nil { - return repoConfig, err - } - oidcClientProperties := map[string]interface{}{} - for _, oidcProperty := range OidcProperties { - if val, exists := propertiesMap[string(oidcProperty)]; exists { - oidcClientProperties[string(oidcProperty)] = val - } else { - return repoConfig, missingOidcSecretProperty(oidcProperty) - } + if status.Applied.AuthzConfig.OidcAuthz.TokenEnvVar != nil { + oidcClientProperties[string(OidcTokenEnvVar)] = *status.Applied.AuthzConfig.OidcAuthz.TokenEnvVar + } + if len(oidcClientProperties) > 0 { + repoConfig.AuthzConfig.OidcParameters = oidcClientProperties } - repoConfig.AuthzConfig.OidcParameters = oidcClientProperties } } return repoConfig, nil diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index 10ac3538a99..4d5e56a9556 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -91,6 +91,8 @@ const ( OidcClientSecret OidcPropertyType = "client_secret" OidcUsername OidcPropertyType = "username" OidcPassword OidcPropertyType = "password" + OidcTokenEnvVar OidcPropertyType = "token_env_var" + OidcVerifySsl OidcPropertyType = "verify_ssl" OidcMissingSecretError string = "missing OIDC secret: %s" ) @@ -208,9 +210,8 @@ var ( }, } - OidcServerProperties = []OidcPropertyType{OidcClientId, OidcAuthDiscoveryUrl} - OidcClientProperties = []OidcPropertyType{OidcClientSecret, OidcUsername, OidcPassword} - OidcProperties = []OidcPropertyType{OidcClientId, OidcAuthDiscoveryUrl, OidcClientSecret, OidcUsername, OidcPassword} + OidcServerProperties = []OidcPropertyType{OidcClientId, OidcAuthDiscoveryUrl} + OidcOptionalSecretProperties = []OidcPropertyType{OidcClientSecret, OidcUsername, OidcPassword} ) // 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. From a1c75de21f4d00168ca8e8f8be23f8193df9068d Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 24 Mar 2026 13:51:33 +0530 Subject: [PATCH 25/39] Reverted kustomization.yaml Signed-off-by: Aniket Paluskar --- infra/feast-operator/config/manager/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/feast-operator/config/manager/kustomization.yaml b/infra/feast-operator/config/manager/kustomization.yaml index ed438c33f39..4a0a78531bb 100644 --- a/infra/feast-operator/config/manager/kustomization.yaml +++ b/infra/feast-operator/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: quay.io/aniket-redhat/feast-operator - newTag: oidc-op-0.1 + newName: quay.io/feastdev/feast-operator + newTag: 0.61.0 From 88a389b5447cd209ba8ce5467c379a783215e242 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 25 Mar 2026 13:33:39 +0530 Subject: [PATCH 26/39] fix: Harden OIDC token parsing and make client_id optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Catch PyJWTError (not just InvalidTokenError) in token decode path - Guard _get_intra_comm_user against malformed tokens (DecodeError) - Reduce _extract_claim log noise: warning → debug for optional claims - Make client_id optional on server config (skip roles when absent) - Move client_id from required to optional in operator config Made-with: Cursor Signed-off-by: Aniket Paluskar --- .../featurestore_controller_oidc_auth_test.go | 2 +- .../controller/services/services_types.go | 4 ++-- .../permissions/auth/oidc_token_parser.py | 23 ++++++++++++------- sdk/python/feast/permissions/auth_model.py | 4 ++-- sdk/python/feast/repo_config.py | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) 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 11c7b41c422..7e0ffb1b299 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 @@ -523,7 +523,7 @@ func createValidOidcSecret(secretName string) *corev1.Secret { func createInvalidOidcSecret(secretName string) *corev1.Secret { oidcProperties := validOidcSecretMap() - delete(oidcProperties, string(services.OidcClientId)) + delete(oidcProperties, string(services.OidcAuthDiscoveryUrl)) secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index 4d5e56a9556..30ae9798b60 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -210,8 +210,8 @@ var ( }, } - OidcServerProperties = []OidcPropertyType{OidcClientId, OidcAuthDiscoveryUrl} - OidcOptionalSecretProperties = []OidcPropertyType{OidcClientSecret, OidcUsername, OidcPassword} + OidcServerProperties = []OidcPropertyType{OidcAuthDiscoveryUrl} + OidcOptionalSecretProperties = []OidcPropertyType{OidcClientId, OidcClientSecret, OidcUsername, OidcPassword} ) // 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. diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index b42714ff98e..b82074e046e 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -79,13 +79,13 @@ def _extract_claim(data: dict, *keys: str, expected_type: type = list): path = ".".join(keys) for key in keys: if not isinstance(node, dict) or key not in node: - logger.warning( + logger.debug( f"Missing {key} in access token claim path '{path}'. Defaulting to {expected_type()}." ) return expected_type() node = node[key] if not isinstance(node, expected_type): - logger.warning( + logger.debug( f"Expected {expected_type.__name__} at '{path}', got {type(node).__name__}. Defaulting to {expected_type()}." ) return expected_type() @@ -172,8 +172,12 @@ async def user_details_from_access_token(self, access_token: str) -> User: data = self._decode_token(access_token) current_user = self._extract_username_or_raise_error(data) - roles = self._extract_claim( - data, "resource_access", self._auth_config.client_id, "roles" + roles = ( + self._extract_claim( + data, "resource_access", self._auth_config.client_id, "roles" + ) + if self._auth_config.client_id + else [] ) groups = self._extract_claim(data, "groups") @@ -185,7 +189,7 @@ async def user_details_from_access_token(self, access_token: str) -> User: roles=roles, groups=groups, ) - except jwt.exceptions.InvalidTokenError: + except jwt.exceptions.PyJWTError: logger.exception("Exception while parsing the token:") raise AuthenticationError("Invalid token.") @@ -225,9 +229,12 @@ def _get_intra_comm_user(self, access_token: str) -> Optional[User]: intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") if intra_communication_base64: - decoded_token = jwt.decode( - access_token, options={"verify_signature": False} - ) + try: + decoded_token = jwt.decode( + access_token, options={"verify_signature": False} + ) + except jwt.exceptions.DecodeError: + return None if "preferred_username" in decoded_token: preferred_username: str = decoded_token["preferred_username"] if ( diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index c1f49921b83..6650189dac8 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -35,13 +35,13 @@ class AuthConfig(FeastConfigBaseModel): class OidcAuthConfig(AuthConfig): auth_discovery_url: str - client_id: str + client_id: Optional[str] = None verify_ssl: bool = True class OidcClientAuthConfig(OidcAuthConfig): auth_discovery_url: Optional[str] = None # type: ignore[assignment] - client_id: Optional[str] = None # type: ignore[assignment] + client_id: Optional[str] = None username: Optional[str] = None password: Optional[str] = None diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 75073018737..208307dc5d5 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -135,7 +135,7 @@ def _is_oidc_client_config(auth_dict: dict) -> bool: if auth_dict.get("type") != AuthType.OIDC.value: return False has_client_keys = bool(_OIDC_CLIENT_KEYS & auth_dict.keys()) - has_server_keys = "auth_discovery_url" in auth_dict or "client_id" in auth_dict + has_server_keys = "auth_discovery_url" in auth_dict return has_client_keys or not has_server_keys From f632686892355f27342bd9c7d423ba53ad922901 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Wed, 1 Apr 2026 22:03:47 +0530 Subject: [PATCH 27/39] cache K8s client, eliminate double JWT decode, improve error messages Signed-off-by: Aniket Paluskar --- .../api/v1/featurestore_types.go | 3 +- .../permissions/auth/oidc_token_parser.py | 45 +++++++------------ sdk/python/feast/permissions/auth_model.py | 3 +- .../oidc_authentication_client_manager.py | 9 +++- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index a8c2dc7faed..bb66da5bf2a 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -715,7 +715,8 @@ type OidcAuthz struct { // When unset, each top-level key in the Secret is treated as a separate OIDC property. // +optional SecretKeyName string `json:"secretKeyName,omitempty"` - // The name of the environment variable that client pods will use to read a pre-existing OIDC token. + // The name of the environment variable that Feast SDK client pods (e.g. workbenches, application pods) + // will read a pre-existing OIDC token from. // When set, the client feature_store.yaml will include token_env_var with this value. // When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. // +optional diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index b82074e046e..37cb69447b2 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -39,6 +39,7 @@ def __init__(self, auth_config: OidcAuthConfig): self._auth_config.auth_discovery_url, verify_ssl=self._auth_config.verify_ssl, ) + self._k8s_auth_api = None async def _validate_token(self, access_token: str): """ @@ -117,24 +118,14 @@ def _decode_token(self, access_token: str) -> dict: leeway=10, # accepts tokens generated up to 10 seconds in the past, in case of clock skew ) - @staticmethod - def _is_kubernetes_token(access_token: str) -> bool: - """Check if the token contains the ``kubernetes.io`` claim (a dict with namespace, pod, serviceaccount).""" - try: - unverified = jwt.decode(access_token, options={"verify_signature": False}) - except jwt.exceptions.DecodeError as e: - raise AuthenticationError(f"Failed to decode token: {e}") - return isinstance(unverified.get("kubernetes.io"), dict) - async def user_details_from_access_token(self, access_token: str) -> User: """ Validate the access token then decode it to extract the user credentials, roles, and groups. - Kubernetes service-account tokens (identified by the ``kubernetes.io`` - claim) are delegated to the K8s parser when available (namespaces are - extracted there, not here — Keycloak JWTs don't carry namespace claims). - All other tokens follow the standard Keycloak JWKS validation path. + A single unverified decode is performed upfront for lightweight routing: + intra-server communication, Kubernetes SA tokens (identified by the + ``kubernetes.io`` claim), or standard OIDC/Keycloak JWKS validation. Returns: User: Current user, with associated roles, groups, or namespaces. @@ -142,13 +133,16 @@ async def user_details_from_access_token(self, access_token: str) -> User: Raises: AuthenticationError if any error happens. """ + try: + unverified = jwt.decode(access_token, options={"verify_signature": False}) + except jwt.exceptions.DecodeError as e: + raise AuthenticationError(f"Failed to decode token: {e}") - # check if intra server communication - user = self._get_intra_comm_user(access_token) + user = self._get_intra_comm_user(unverified) if user: return user - if self._is_kubernetes_token(access_token): + if isinstance(unverified.get("kubernetes.io"), dict): logger.debug("Detected kubernetes.io claim — validating via TokenReview") try: return await self._validate_k8s_sa_token_and_extract_namespace( @@ -193,8 +187,7 @@ async def user_details_from_access_token(self, access_token: str) -> User: logger.exception("Exception while parsing the token:") raise AuthenticationError("Invalid token.") - @staticmethod - async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> User: + async def _validate_k8s_sa_token_and_extract_namespace(self, access_token: str) -> User: """Validate a K8s SA token via TokenReview and extract the namespace. Lightweight alternative to full KubernetesTokenParser — only validates @@ -204,13 +197,14 @@ async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> Use """ from kubernetes import client, config - config.load_incluster_config() - auth_v1 = client.AuthenticationV1Api() + if self._k8s_auth_api is None: + config.load_incluster_config() + self._k8s_auth_api = client.AuthenticationV1Api() token_review = client.V1TokenReview( spec=client.V1TokenReviewSpec(token=access_token) ) - response = auth_v1.create_token_review(token_review) + response = self._k8s_auth_api.create_token_review(token_review) if not response.status.authenticated: raise AuthenticationError( @@ -225,16 +219,11 @@ async def _validate_k8s_sa_token_and_extract_namespace(access_token: str) -> Use logger.info(f"SA token validated — user: {username}, namespaces: {namespaces}") return User(username=username, roles=[], groups=[], namespaces=namespaces) - def _get_intra_comm_user(self, access_token: str) -> Optional[User]: + @staticmethod + def _get_intra_comm_user(decoded_token: dict) -> Optional[User]: intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") if intra_communication_base64: - try: - decoded_token = jwt.decode( - access_token, options={"verify_signature": False} - ) - except jwt.exceptions.DecodeError: - return None if "preferred_username" in decoded_token: preferred_username: str = decoded_token["preferred_username"] if ( diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 6650189dac8..3ad6da3ebbb 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -19,7 +19,8 @@ def _check_mutually_exclusive(**groups: Tuple[object, ...]) -> None: if partial: raise ValueError( f"Incomplete configuration for '{partial[0]}': " - f"all fields in this group are required when any is set." + f"configure all of these fields together, or none at all. " + f"Check the documentation for valid credential combinations." ) active = [name for name, vals in groups.items() if all(vals)] if len(active) > 1: diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index 4676db385d4..cb86f033faa 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -1,5 +1,6 @@ import logging import os +from typing import Optional import jwt import requests @@ -56,7 +57,7 @@ def get_token(self): ) @staticmethod - def _read_sa_token() -> str | None: + def _read_sa_token() -> Optional[str]: """Read the Kubernetes service account token from the standard mount path.""" if os.path.isfile(SA_TOKEN_PATH): with open(SA_TOKEN_PATH) as f: @@ -67,7 +68,11 @@ def _read_sa_token() -> str | None: def _fetch_token_from_idp(self) -> str: """Obtain an access token via client_credentials or ROPG flow.""" - assert self.auth_config.auth_discovery_url is not None + if self.auth_config.auth_discovery_url is None: + raise ValueError( + "auth_discovery_url is required for IDP token fetch " + "(client_credentials or ROPG flow)." + ) token_endpoint = OIDCDiscoveryService( self.auth_config.auth_discovery_url, verify_ssl=self.auth_config.verify_ssl, From 45666dad23c4c7d405d0e79d98dc3b20a27f92bc Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Thu, 2 Apr 2026 01:10:35 +0530 Subject: [PATCH 28/39] Minor formatting Signed-off-by: Aniket Paluskar --- sdk/python/feast/permissions/auth/oidc_token_parser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 37cb69447b2..3fb87de345a 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -187,7 +187,9 @@ async def user_details_from_access_token(self, access_token: str) -> User: logger.exception("Exception while parsing the token:") raise AuthenticationError("Invalid token.") - async def _validate_k8s_sa_token_and_extract_namespace(self, access_token: str) -> User: + async def _validate_k8s_sa_token_and_extract_namespace( + self, access_token: str + ) -> User: """Validate a K8s SA token via TokenReview and extract the namespace. Lightweight alternative to full KubernetesTokenParser — only validates From 0a59ad2613f1a321bd2393448be775b7abd0c5df Mon Sep 17 00:00:00 2001 From: Gowtham Shanmugasundaram Date: Tue, 31 Mar 2026 18:21:14 +0530 Subject: [PATCH 29/39] feat(odh): wire OIDC_ISSUER_URL from params.env into operator pod - Add OIDC_ISSUER_URL to manager Deployment env (base) - Add param to ODH/RHOAI params.env with empty default - Kustomize replacements from feast-operator-parameters ConfigMap - Document Open Data Hub operator integration Pairs with opendatahub-operator injecting OIDC_ISSUER_URL at reconcile time from GatewayConfig when the cluster uses external OIDC (RHOAIENG-55767). Made-with: Cursor Signed-off-by: Aniket Paluskar --- infra/feast-operator/config/manager/manager.yaml | 4 ++++ .../config/overlays/odh/kustomization.yaml | 10 ++++++++++ .../feast-operator/config/overlays/odh/params.env | 2 ++ .../config/overlays/rhoai/kustomization.yaml | 10 ++++++++++ .../config/overlays/rhoai/params.env | 4 +++- .../docs/odh-operator-parameters.md | 15 +++++++++++++++ 6 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 infra/feast-operator/docs/odh-operator-parameters.md diff --git a/infra/feast-operator/config/manager/manager.yaml b/infra/feast-operator/config/manager/manager.yaml index 8107749fce5..2fddf4725ba 100644 --- a/infra/feast-operator/config/manager/manager.yaml +++ b/infra/feast-operator/config/manager/manager.yaml @@ -77,6 +77,10 @@ spec: value: feast:latest - name: RELATED_IMAGE_CRON_JOB value: origin-cli:latest + # Injected from params.env via kustomize replacements (ODH/RHOAI overlays). + # Open Data Hub operator sets this from GatewayConfig when the cluster uses external OIDC. + - name: OIDC_ISSUER_URL + value: "" livenessProbe: httpGet: path: /healthz diff --git a/infra/feast-operator/config/overlays/odh/kustomization.yaml b/infra/feast-operator/config/overlays/odh/kustomization.yaml index cf751d178bd..044614f01fe 100644 --- a/infra/feast-operator/config/overlays/odh/kustomization.yaml +++ b/infra/feast-operator/config/overlays/odh/kustomization.yaml @@ -52,3 +52,13 @@ replacements: name: controller-manager fieldPaths: - spec.template.spec.containers.[name=manager].env.[name=RELATED_IMAGE_CRON_JOB].value + - source: + kind: ConfigMap + name: feast-operator-parameters + fieldPath: data.OIDC_ISSUER_URL + targets: + - select: + kind: Deployment + name: controller-manager + fieldPaths: + - spec.template.spec.containers.[name=manager].env.[name=OIDC_ISSUER_URL].value diff --git a/infra/feast-operator/config/overlays/odh/params.env b/infra/feast-operator/config/overlays/odh/params.env index d7b0233b998..6add626285c 100644 --- a/infra/feast-operator/config/overlays/odh/params.env +++ b/infra/feast-operator/config/overlays/odh/params.env @@ -1,3 +1,5 @@ RELATED_IMAGE_FEAST_OPERATOR=quay.io/feastdev/feast-operator:0.61.0 RELATED_IMAGE_FEATURE_SERVER=quay.io/feastdev/feature-server:0.61.0 RELATED_IMAGE_CRON_JOB=quay.io/openshift/origin-cli:4.17 +# Set at deploy time by the Open Data Hub operator from GatewayConfig (external OIDC). +OIDC_ISSUER_URL= diff --git a/infra/feast-operator/config/overlays/rhoai/kustomization.yaml b/infra/feast-operator/config/overlays/rhoai/kustomization.yaml index 4917579ef28..b9d075bdf39 100644 --- a/infra/feast-operator/config/overlays/rhoai/kustomization.yaml +++ b/infra/feast-operator/config/overlays/rhoai/kustomization.yaml @@ -52,3 +52,13 @@ replacements: name: controller-manager fieldPaths: - spec.template.spec.containers.[name=manager].env.[name=RELATED_IMAGE_CRON_JOB].value + - source: + kind: ConfigMap + name: feast-operator-parameters + fieldPath: data.OIDC_ISSUER_URL + targets: + - select: + kind: Deployment + name: controller-manager + fieldPaths: + - spec.template.spec.containers.[name=manager].env.[name=OIDC_ISSUER_URL].value diff --git a/infra/feast-operator/config/overlays/rhoai/params.env b/infra/feast-operator/config/overlays/rhoai/params.env index ce50a9f1412..12585765ee5 100644 --- a/infra/feast-operator/config/overlays/rhoai/params.env +++ b/infra/feast-operator/config/overlays/rhoai/params.env @@ -1,3 +1,5 @@ RELATED_IMAGE_FEAST_OPERATOR=quay.io/feastdev/feast-operator:0.61.0 RELATED_IMAGE_FEATURE_SERVER=quay.io/feastdev/feature-server:0.61.0 -RELATED_IMAGE_CRON_JOB=registry.redhat.io/openshift4/ose-cli@sha256:bc35a9fc663baf0d6493cc57e89e77a240a36c43cf38fb78d8e61d3b87cf5cc5 \ No newline at end of file +RELATED_IMAGE_CRON_JOB=registry.redhat.io/openshift4/ose-cli@sha256:bc35a9fc663baf0d6493cc57e89e77a240a36c43cf38fb78d8e61d3b87cf5cc5 +# Set at deploy time by the Open Data Hub operator from GatewayConfig (external OIDC). +OIDC_ISSUER_URL= \ No newline at end of file diff --git a/infra/feast-operator/docs/odh-operator-parameters.md b/infra/feast-operator/docs/odh-operator-parameters.md new file mode 100644 index 00000000000..8b29d4d1ca8 --- /dev/null +++ b/infra/feast-operator/docs/odh-operator-parameters.md @@ -0,0 +1,15 @@ +# Open Data Hub / RHOAI operator parameters + +These values are supplied through the Feast operator **`params.env`** files in the **ODH** and **RHOAI** overlays (`config/overlays/odh/params.env`, `config/overlays/rhoai/params.env`). The Open Data Hub operator updates keys in `params.env` before rendering; Kustomize **`replacements`** copy them into the controller Deployment. + +## `OIDC_ISSUER_URL` + +**Purpose:** OIDC issuer URL when the OpenShift cluster uses external OIDC (for example Keycloak). The Feast operator process receives it as the **`OIDC_ISSUER_URL`** environment variable. An empty value means the cluster is not using external OIDC in this integration path (OpenShift OAuth / default behavior). + +**Manifest parameter:** `OIDC_ISSUER_URL` in `params.env`. + +**Injected into:** `controller-manager` Deployment, `manager` container. + +**Set by:** Open Data Hub operator (Feast component reconcile), from `GatewayConfig.spec.oidc.issuerURL` when cluster authentication is OIDC. + +**Consumption:** Operator code should read `os.Getenv("OIDC_ISSUER_URL")` (or equivalent) where JWKS / OIDC discovery is required for managed workloads. From 3557a15af54b0892d39b9c876e64e373954df068 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Mon, 6 Apr 2026 22:55:25 +0530 Subject: [PATCH 30/39] Add issuerUrl to OidcAuthz CRD and OIDC_ISSUER_URL env var support for Secret-less OIDC configuration Signed-off-by: Aniket Paluskar --- .../api/v1/featurestore_types.go | 11 +++- .../api/v1/zz_generated.deepcopy.go | 6 +- .../feast-operator.clusterserviceversion.yaml | 14 +++- .../manifests/feast.dev_featurestores.yaml | 28 ++++---- .../crd/bases/feast.dev_featurestores.yaml | 28 ++++---- .../config/manager/kustomization.yaml | 4 +- infra/feast-operator/dist/install.yaml | 30 +++++---- infra/feast-operator/docs/api/markdown/ref.md | 9 ++- .../featurestore_controller_oidc_auth_test.go | 6 +- .../controller/services/repo_config.go | 65 ++++++++++++++----- .../controller/services/repo_config_test.go | 65 +++++++++++++------ .../controller/services/services_types.go | 3 +- .../internal/controller/services/util.go | 3 - 13 files changed, 182 insertions(+), 90 deletions(-) diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index bb66da5bf2a..3e4ba1e6072 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -708,7 +708,16 @@ type KubernetesAuthz struct { // OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. // https://auth0.com/docs/authenticate/protocols/openid-connect-protocol type OidcAuthz struct { - SecretRef corev1.LocalObjectReference `json:"secretRef"` + // The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + // The operator derives the OIDC discovery endpoint by appending /.well-known/openid-configuration. + // When set, no Secret is required for basic OIDC authentication. + // +optional + // +kubebuilder:validation:Pattern=`^https://\S+$` + IssuerUrl string `json:"issuerUrl,omitempty"` + // Reference to a Secret containing OIDC properties (auth_discovery_url, client_id, client_secret, etc.). + // When both issuerUrl and a Secret with auth_discovery_url are provided, issuerUrl takes precedence. + // +optional + SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` // The key within the Secret that contains the OIDC configuration as a YAML-encoded value. // When set, only this key is read and its YAML value is expected to contain the OIDC properties // (e.g. client_id, auth_discovery_url). This allows sharing a single Secret across services. diff --git a/infra/feast-operator/api/v1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1/zz_generated.deepcopy.go index 3201787c5dd..b875a1c0ff2 100644 --- a/infra/feast-operator/api/v1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1/zz_generated.deepcopy.go @@ -744,7 +744,11 @@ func (in *OfflineStorePersistence) DeepCopy() *OfflineStorePersistence { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OidcAuthz) DeepCopyInto(out *OidcAuthz) { *out = *in - out.SecretRef = in.SecretRef + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } if in.TokenEnvVar != nil { in, out := &in.TokenEnvVar, &out.TokenEnvVar *out = new(string) diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index 7278bd3faf5..8f752ca9727 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -50,7 +50,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-03-24T07:09:46Z" + createdAt: "2026-04-06T12:13:16Z" operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.61.0 @@ -175,6 +175,17 @@ spec: - get - patch - update + - apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - watch - apiGroups: - policy resources: @@ -259,6 +270,7 @@ spec: value: quay.io/feastdev/feature-server:0.61.0 - name: RELATED_IMAGE_CRON_JOB value: quay.io/openshift/origin-cli:4.17 + - name: OIDC_ISSUER_URL image: quay.io/feastdev/feast-operator:0.61.0 livenessProbe: httpGet: diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index de351dda580..5ce51131a93 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -62,14 +62,17 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + issuerUrl: + description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + pattern: ^https://\S+$ + type: string secretKeyName: description: The key within the Secret that contains the OIDC configuration as a YAML-encoded value. type: string secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. + description: Reference to a Secret containing OIDC properties + (auth_discovery_url, client_id, client_secret, etc.). properties: name: default: "" @@ -81,16 +84,14 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that client - pods will use to read a pre-existing OIDC token. + description: The name of the environment variable that Feast + SDK client pods (e.g. type: string verifySSL: description: |- Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. type: boolean - required: - - secretRef type: object type: object x-kubernetes-validations: @@ -5770,14 +5771,17 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + issuerUrl: + description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + pattern: ^https://\S+$ + type: string secretKeyName: description: The key within the Secret that contains the OIDC configuration as a YAML-encoded value. type: string secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. + description: Reference to a Secret containing OIDC properties + (auth_discovery_url, client_id, client_secret, etc.). properties: name: default: "" @@ -5790,15 +5794,13 @@ spec: x-kubernetes-map-type: atomic tokenEnvVar: description: The name of the environment variable that - client pods will use to read a pre-existing OIDC token. + Feast SDK client pods (e.g. type: string verifySSL: description: |- Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. type: boolean - required: - - secretRef type: object type: object x-kubernetes-validations: diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index a0140b751cc..a78752e1c66 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -62,14 +62,17 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + issuerUrl: + description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + pattern: ^https://\S+$ + type: string secretKeyName: description: The key within the Secret that contains the OIDC configuration as a YAML-encoded value. type: string secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. + description: Reference to a Secret containing OIDC properties + (auth_discovery_url, client_id, client_secret, etc.). properties: name: default: "" @@ -81,16 +84,14 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that client - pods will use to read a pre-existing OIDC token. + description: The name of the environment variable that Feast + SDK client pods (e.g. type: string verifySSL: description: |- Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. type: boolean - required: - - secretRef type: object type: object x-kubernetes-validations: @@ -5770,14 +5771,17 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + issuerUrl: + description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + pattern: ^https://\S+$ + type: string secretKeyName: description: The key within the Secret that contains the OIDC configuration as a YAML-encoded value. type: string secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. + description: Reference to a Secret containing OIDC properties + (auth_discovery_url, client_id, client_secret, etc.). properties: name: default: "" @@ -5790,15 +5794,13 @@ spec: x-kubernetes-map-type: atomic tokenEnvVar: description: The name of the environment variable that - client pods will use to read a pre-existing OIDC token. + Feast SDK client pods (e.g. type: string verifySSL: description: |- Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. type: boolean - required: - - secretRef type: object type: object x-kubernetes-validations: diff --git a/infra/feast-operator/config/manager/kustomization.yaml b/infra/feast-operator/config/manager/kustomization.yaml index 4a0a78531bb..c7a5af8243a 100644 --- a/infra/feast-operator/config/manager/kustomization.yaml +++ b/infra/feast-operator/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: quay.io/feastdev/feast-operator - newTag: 0.61.0 + newName: quay.io/aniket-redhat/feast-operator + newTag: oidc-op-0.4 diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index c68a6567159..efa8d133cae 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -70,14 +70,17 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + issuerUrl: + description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + pattern: ^https://\S+$ + type: string secretKeyName: description: The key within the Secret that contains the OIDC configuration as a YAML-encoded value. type: string secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. + description: Reference to a Secret containing OIDC properties + (auth_discovery_url, client_id, client_secret, etc.). properties: name: default: "" @@ -89,16 +92,14 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that client - pods will use to read a pre-existing OIDC token. + description: The name of the environment variable that Feast + SDK client pods (e.g. type: string verifySSL: description: |- Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. type: boolean - required: - - secretRef type: object type: object x-kubernetes-validations: @@ -5778,14 +5779,17 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + issuerUrl: + description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + pattern: ^https://\S+$ + type: string secretKeyName: description: The key within the Secret that contains the OIDC configuration as a YAML-encoded value. type: string secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. + description: Reference to a Secret containing OIDC properties + (auth_discovery_url, client_id, client_secret, etc.). properties: name: default: "" @@ -5798,15 +5802,13 @@ spec: x-kubernetes-map-type: atomic tokenEnvVar: description: The name of the environment variable that - client pods will use to read a pre-existing OIDC token. + Feast SDK client pods (e.g. type: string verifySSL: description: |- Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. type: boolean - required: - - secretRef type: object type: object x-kubernetes-validations: @@ -20609,6 +20611,8 @@ spec: value: quay.io/feastdev/feature-server:0.61.0 - name: RELATED_IMAGE_CRON_JOB value: quay.io/openshift/origin-cli:4.17 + - name: OIDC_ISSUER_URL + value: "" image: quay.io/feastdev/feast-operator:0.61.0 livenessProbe: httpGet: diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index 9003a30de6f..c58d9032f02 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -542,12 +542,17 @@ _Appears in:_ | Field | Description | | --- | --- | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | +| `issuerUrl` _string_ | The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). +The operator derives the OIDC discovery endpoint by appending /.well-known/openid-configuration. +When set, no Secret is required for basic OIDC authentication. | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Reference to a Secret containing OIDC properties (auth_discovery_url, client_id, client_secret, etc.). +When both issuerUrl and a Secret with auth_discovery_url are provided, issuerUrl takes precedence. | | `secretKeyName` _string_ | The key within the Secret that contains the OIDC configuration as a YAML-encoded value. When set, only this key is read and its YAML value is expected to contain the OIDC properties (e.g. client_id, auth_discovery_url). This allows sharing a single Secret across services. When unset, each top-level key in the Secret is treated as a separate OIDC property. | -| `tokenEnvVar` _string_ | The name of the environment variable that client pods will use to read a pre-existing OIDC token. +| `tokenEnvVar` _string_ | The name of the environment variable that Feast SDK client pods (e.g. workbenches, application pods) +will read a pre-existing OIDC token from. When set, the client feature_store.yaml will include token_env_var with this value. When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. | | `verifySSL` _boolean_ | Whether to verify SSL certificates when communicating with the OIDC provider. 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 7e0ffb1b299..16b57f8d7f5 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 @@ -77,7 +77,7 @@ var _ = Describe("FeatureStore Controller-OIDC authorization", func() { if err != nil && errors.IsNotFound(err) { resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{}, withEnvFrom()) resource.Spec.AuthzConfig = &feastdevv1.AuthzConfig{OidcAuthz: &feastdevv1.OidcAuthz{ - SecretRef: corev1.LocalObjectReference{ + SecretRef: &corev1.LocalObjectReference{ Name: oidcSecretName, }, }} @@ -134,7 +134,7 @@ var _ = Describe("FeatureStore Controller-OIDC authorization", func() { Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) expectedAuthzConfig := &feastdevv1.AuthzConfig{ OidcAuthz: &feastdevv1.OidcAuthz{ - SecretRef: corev1.LocalObjectReference{ + SecretRef: &corev1.LocalObjectReference{ Name: oidcSecretName, }, }, @@ -476,7 +476,7 @@ var _ = Describe("FeatureStore Controller-OIDC authorization", func() { Expect(cond.Status).To(Equal(metav1.ConditionFalse)) Expect(cond.Reason).To(Equal(feastdevv1.FailedReason)) Expect(cond.Type).To(Equal(feastdevv1.ReadyType)) - Expect(cond.Message).To(ContainSubstring("missing OIDC")) + Expect(cond.Message).To(ContainSubstring("OIDC discovery URL")) }) }) }) diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index a7c4fcd4de6..813428e92d9 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -19,6 +19,7 @@ package services import ( "encoding/base64" "fmt" + "os" "path" "strings" @@ -26,6 +27,8 @@ import ( "gopkg.in/yaml.v3" ) +const oidcIssuerUrlEnvVar = "OIDC_ISSUER_URL" + // GetServiceFeatureStoreYamlBase64 returns a base64 encoded feature_store.yaml config for the feast service func (feast *FeastServices) GetServiceFeatureStoreYamlBase64() (string, error) { fsYaml, err := feast.getServiceFeatureStoreYaml() @@ -104,27 +107,30 @@ func getBaseServiceRepoConfig( appliedSpec := featureStore.Status.Applied if appliedSpec.AuthzConfig != nil && appliedSpec.AuthzConfig.OidcAuthz != nil { repoConfig.AuthzConfig = AuthzConfig{Type: OidcAuthType} - - propertiesMap, authSecretErr := secretExtractionFunc("", appliedSpec.AuthzConfig.OidcAuthz.SecretRef.Name, appliedSpec.AuthzConfig.OidcAuthz.SecretKeyName) - if authSecretErr != nil { - return repoConfig, authSecretErr - } - + oidcAuthz := appliedSpec.AuthzConfig.OidcAuthz oidcParameters := map[string]interface{}{} - for _, oidcProperty := range OidcServerProperties { - if val, exists := propertiesMap[string(oidcProperty)]; exists { - oidcParameters[string(oidcProperty)] = val - } else { - return repoConfig, missingOidcSecretProperty(oidcProperty) + + var secretProperties map[string]interface{} + if oidcAuthz.SecretRef != nil { + secretProperties, err = secretExtractionFunc("", oidcAuthz.SecretRef.Name, oidcAuthz.SecretKeyName) + if err != nil { + return repoConfig, err } - } - for _, oidcProperty := range OidcOptionalSecretProperties { - if val, exists := propertiesMap[string(oidcProperty)]; exists { - oidcParameters[string(oidcProperty)] = val + for _, prop := range OidcOptionalSecretProperties { + if val, exists := secretProperties[string(prop)]; exists { + oidcParameters[string(prop)] = val + } } } - if appliedSpec.AuthzConfig.OidcAuthz.VerifySSL != nil { - oidcParameters[string(OidcVerifySsl)] = *appliedSpec.AuthzConfig.OidcAuthz.VerifySSL + + discoveryUrl, err := resolveAuthDiscoveryUrl(oidcAuthz, secretProperties) + if err != nil { + return repoConfig, err + } + oidcParameters[string(OidcAuthDiscoveryUrl)] = discoveryUrl + + if oidcAuthz.VerifySSL != nil { + oidcParameters[string(OidcVerifySsl)] = *oidcAuthz.VerifySSL } repoConfig.AuthzConfig.OidcParameters = oidcParameters } else { @@ -134,6 +140,31 @@ func getBaseServiceRepoConfig( return repoConfig, nil } +// resolveAuthDiscoveryUrl determines the OIDC discovery URL from the first available source. +// Priority: CR issuerUrl > Secret auth_discovery_url > OIDC_ISSUER_URL env var. +func resolveAuthDiscoveryUrl(oidcAuthz *feastdevv1.OidcAuthz, secretProperties map[string]interface{}) (string, error) { + if oidcAuthz.IssuerUrl != "" { + return issuerToDiscoveryUrl(oidcAuthz.IssuerUrl), nil + } + + if val, ok := secretProperties[string(OidcAuthDiscoveryUrl)]; ok { + if s, ok := val.(string); ok && s != "" { + return s, nil + } + } + + if envIssuer := os.Getenv(oidcIssuerUrlEnvVar); envIssuer != "" { + return issuerToDiscoveryUrl(envIssuer), nil + } + + return "", fmt.Errorf("no OIDC discovery URL configured: set issuerUrl on the OidcAuthz CR, "+ + "include auth_discovery_url in the referenced Secret, or ensure the %s environment variable is set on the operator pod", oidcIssuerUrlEnvVar) +} + +func issuerToDiscoveryUrl(issuerUrl string) string { + return strings.TrimRight(issuerUrl, "/") + "/.well-known/openid-configuration" +} + func setRepoConfigRegistry(services *feastdevv1.FeatureStoreServices, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), repoConfig *RepoConfig) error { registryPersistence := services.Registry.Local.Persistence 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 70869568dea..45d08d86631 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -195,10 +195,10 @@ var _ = Describe("Repo Config", func() { Expect(repoConfig.OnlineStore).To(Equal(defaultOnlineStoreConfig(featureStore))) Expect(repoConfig.Registry).To(Equal(defaultRegistryConfig(featureStore))) - By("Having oidc authorization") + By("Having oidc authorization with Secret") featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ OidcAuthz: &feastdevv1.OidcAuthz{ - SecretRef: corev1.LocalObjectReference{ + SecretRef: &corev1.LocalObjectReference{ Name: "oidc-secret", }, }, @@ -227,12 +227,33 @@ var _ = Describe("Repo Config", func() { repoConfig, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) - Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(5)) - 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))) + + By("Having oidc authorization with issuerUrl only (no Secret)") + featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ + OidcAuthz: &feastdevv1.OidcAuthz{ + IssuerUrl: "https://keycloak.example.com/realms/test", + }, + } + ApplyDefaultsToStatus(featureStore) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(1)) + Expect(repoConfig.AuthzConfig.OidcParameters[string(OidcAuthDiscoveryUrl)]).To(Equal("https://keycloak.example.com/realms/test/.well-known/openid-configuration")) + + By("Having oidc with issuerUrl on CR and auth_discovery_url in Secret — CR wins") + featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ + OidcAuthz: &feastdevv1.OidcAuthz{ + IssuerUrl: "https://keycloak.example.com/realms/cr-wins", + SecretRef: &corev1.LocalObjectReference{ + Name: "oidc-secret", + }, + }, + } + ApplyDefaultsToStatus(featureStore) + repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.OidcParameters[string(OidcAuthDiscoveryUrl)]).To(Equal("https://keycloak.example.com/realms/cr-wins/.well-known/openid-configuration")) By("Having the all the db services") featureStore = minimalFeatureStore() @@ -301,10 +322,20 @@ var _ = Describe("Repo Config", func() { It("should fail to create the repo configs", func() { featureStore := minimalFeatureStore() - By("Having invalid server oidc authorization") + By("Having oidc with no issuerUrl, no Secret, no env var — should fail") + featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ + OidcAuthz: &feastdevv1.OidcAuthz{}, + } + ApplyDefaultsToStatus(featureStore) + + _, err := getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no OIDC discovery URL configured")) + + By("Having oidc with Secret missing auth_discovery_url and no issuerUrl — should fail") featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ OidcAuthz: &feastdevv1.OidcAuthz{ - SecretRef: corev1.LocalObjectReference{ + SecretRef: &corev1.LocalObjectReference{ Name: "oidc-secret", }, }, @@ -316,17 +347,14 @@ var _ = Describe("Repo Config", func() { string(OidcClientSecret): "client-secret", string(OidcUsername): "username", string(OidcPassword): "password"}) - _, err := getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) - _, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + Expect(err.Error()).To(ContainSubstring("no OIDC discovery URL configured")) By("Having invalid client oidc authorization") featureStore.Spec.AuthzConfig = &feastdevv1.AuthzConfig{ OidcAuthz: &feastdevv1.OidcAuthz{ - SecretRef: corev1.LocalObjectReference{ + SecretRef: &corev1.LocalObjectReference{ Name: "oidc-secret", }, }, @@ -339,10 +367,9 @@ var _ = Describe("Repo Config", func() { string(OidcUsername): "username", string(OidcPassword): "password"}) _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + Expect(err).NotTo(HaveOccurred()) _, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) - Expect(err).To(HaveOccurred()) + Expect(err).NotTo(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) }) }) diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index 30ae9798b60..3f851f5eef8 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -210,8 +210,7 @@ var ( }, } - OidcServerProperties = []OidcPropertyType{OidcAuthDiscoveryUrl} - OidcOptionalSecretProperties = []OidcPropertyType{OidcClientId, OidcClientSecret, OidcUsername, OidcPassword} + OidcOptionalSecretProperties = []OidcPropertyType{OidcAuthDiscoveryUrl, OidcClientId, OidcClientSecret, OidcUsername, OidcPassword} ) // 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. diff --git a/infra/feast-operator/internal/controller/services/util.go b/infra/feast-operator/internal/controller/services/util.go index 3c9fedbe49d..19697d749ba 100644 --- a/infra/feast-operator/internal/controller/services/util.go +++ b/infra/feast-operator/internal/controller/services/util.go @@ -407,9 +407,6 @@ func SetIsOpenShift(cfg *rest.Config) { } } -func missingOidcSecretProperty(property OidcPropertyType) error { - return fmt.Errorf(OidcMissingSecretError, property) -} // getEnvVar returns the position of the EnvVar found by name func getEnvVar(envName string, env []corev1.EnvVar) int { From 30a04c2f5932289cc4142b357a7187c1a6e25228 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 03:02:44 +0530 Subject: [PATCH 31/39] Add caCertConfigMap to OidcAuthz CRD and ca_cert_path to SDK for self-signed OIDC provider TLS verification Signed-off-by: Aniket Paluskar --- .../api/v1/featurestore_types.go | 15 ++++++ .../api/v1/zz_generated.deepcopy.go | 20 ++++++++ .../feast-operator.clusterserviceversion.yaml | 2 +- .../manifests/feast.dev_featurestores.yaml | 31 ++++++++++++ .../crd/bases/feast.dev_featurestores.yaml | 31 ++++++++++++ infra/feast-operator/dist/install.yaml | 31 ++++++++++++ infra/feast-operator/docs/api/markdown/ref.md | 19 +++++++ .../controller/services/repo_config.go | 15 ++++++ .../controller/services/services_types.go | 6 +++ .../internal/controller/services/tls.go | 49 ++++++++++++++++++- .../internal/controller/services/util.go | 1 - .../permissions/auth/oidc_token_parser.py | 5 ++ sdk/python/feast/permissions/auth_model.py | 1 + sdk/python/feast/permissions/oidc_service.py | 16 +++++- 14 files changed, 236 insertions(+), 6 deletions(-) diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index 3e4ba1e6072..eed15db1083 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -734,6 +734,21 @@ type OidcAuthz struct { // Defaults to true. Set to false for self-signed certificates (common in internal OpenShift clusters). // +optional VerifySSL *bool `json:"verifySSL,omitempty"` + // Reference to a ConfigMap containing the CA certificate for the OIDC provider. + // Used when the OIDC provider uses self-signed or custom CA certificates and verifySSL is true. + // On RHOAI/ODH clusters, the operator auto-detects the platform CA bundle; this field is not required. + // +optional + CACertConfigMap *OidcCACertConfigMap `json:"caCertConfigMap,omitempty"` +} + +// OidcCACertConfigMap references a ConfigMap containing a CA certificate for OIDC provider TLS verification. +type OidcCACertConfigMap struct { + // Name of the ConfigMap containing the CA certificate. + Name string `json:"name"` + // Key within the ConfigMap that holds the CA certificate in PEM format. + // Defaults to "ca-bundle.crt" if omitted. + // +optional + Key string `json:"key,omitempty"` } // TlsConfigs configures server TLS for a feast service. in an openshift cluster, this is configured by default using service serving certificates. diff --git a/infra/feast-operator/api/v1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1/zz_generated.deepcopy.go index b875a1c0ff2..443d6f3f9d7 100644 --- a/infra/feast-operator/api/v1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1/zz_generated.deepcopy.go @@ -759,6 +759,11 @@ func (in *OidcAuthz) DeepCopyInto(out *OidcAuthz) { *out = new(bool) **out = **in } + if in.CACertConfigMap != nil { + in, out := &in.CACertConfigMap, &out.CACertConfigMap + *out = new(OidcCACertConfigMap) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OidcAuthz. @@ -771,6 +776,21 @@ func (in *OidcAuthz) DeepCopy() *OidcAuthz { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OidcCACertConfigMap) DeepCopyInto(out *OidcCACertConfigMap) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OidcCACertConfigMap. +func (in *OidcCACertConfigMap) DeepCopy() *OidcCACertConfigMap { + if in == nil { + return nil + } + out := new(OidcCACertConfigMap) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OnlineStore) DeepCopyInto(out *OnlineStore) { *out = *in diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index 8f752ca9727..aa1cd0bb3a2 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -50,7 +50,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-04-06T12:13:16Z" + createdAt: "2026-04-06T19:35:43Z" operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.61.0 diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 5ce51131a93..3a5c21702c5 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -62,6 +62,21 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + caCertConfigMap: + description: Reference to a ConfigMap containing the CA certificate + for the OIDC provider. + properties: + key: + description: |- + Key within the ConfigMap that holds the CA certificate in PEM format. + Defaults to "ca-bundle.crt" if omitted. + type: string + name: + description: Name of the ConfigMap containing the CA certificate. + type: string + required: + - name + type: object issuerUrl: description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). pattern: ^https://\S+$ @@ -5771,6 +5786,22 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + caCertConfigMap: + description: Reference to a ConfigMap containing the CA + certificate for the OIDC provider. + properties: + key: + description: |- + Key within the ConfigMap that holds the CA certificate in PEM format. + Defaults to "ca-bundle.crt" if omitted. + type: string + name: + description: Name of the ConfigMap containing the + CA certificate. + type: string + required: + - name + type: object issuerUrl: description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). pattern: ^https://\S+$ diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index a78752e1c66..c87d99ee38b 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -62,6 +62,21 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + caCertConfigMap: + description: Reference to a ConfigMap containing the CA certificate + for the OIDC provider. + properties: + key: + description: |- + Key within the ConfigMap that holds the CA certificate in PEM format. + Defaults to "ca-bundle.crt" if omitted. + type: string + name: + description: Name of the ConfigMap containing the CA certificate. + type: string + required: + - name + type: object issuerUrl: description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). pattern: ^https://\S+$ @@ -5771,6 +5786,22 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + caCertConfigMap: + description: Reference to a ConfigMap containing the CA + certificate for the OIDC provider. + properties: + key: + description: |- + Key within the ConfigMap that holds the CA certificate in PEM format. + Defaults to "ca-bundle.crt" if omitted. + type: string + name: + description: Name of the ConfigMap containing the + CA certificate. + type: string + required: + - name + type: object issuerUrl: description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). pattern: ^https://\S+$ diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index efa8d133cae..0795e4e0035 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -70,6 +70,21 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + caCertConfigMap: + description: Reference to a ConfigMap containing the CA certificate + for the OIDC provider. + properties: + key: + description: |- + Key within the ConfigMap that holds the CA certificate in PEM format. + Defaults to "ca-bundle.crt" if omitted. + type: string + name: + description: Name of the ConfigMap containing the CA certificate. + type: string + required: + - name + type: object issuerUrl: description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). pattern: ^https://\S+$ @@ -5779,6 +5794,22 @@ spec: OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. https://auth0. properties: + caCertConfigMap: + description: Reference to a ConfigMap containing the CA + certificate for the OIDC provider. + properties: + key: + description: |- + Key within the ConfigMap that holds the CA certificate in PEM format. + Defaults to "ca-bundle.crt" if omitted. + type: string + name: + description: Name of the ConfigMap containing the + CA certificate. + type: string + required: + - name + type: object issuerUrl: description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). pattern: ^https://\S+$ diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index c58d9032f02..77276c4cc70 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -557,6 +557,25 @@ When set, the client feature_store.yaml will include token_env_var with this val When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. | | `verifySSL` _boolean_ | Whether to verify SSL certificates when communicating with the OIDC provider. Defaults to true. Set to false for self-signed certificates (common in internal OpenShift clusters). | +| `caCertConfigMap` _[OidcCACertConfigMap](#oidccacertconfigmap)_ | Reference to a ConfigMap containing the CA certificate for the OIDC provider. +Used when the OIDC provider uses self-signed or custom CA certificates and verifySSL is true. +On RHOAI/ODH clusters, the operator auto-detects the platform CA bundle; this field is not required. | + + +#### OidcCACertConfigMap + + + +OidcCACertConfigMap references a ConfigMap containing a CA certificate for OIDC provider TLS verification. + +_Appears in:_ +- [OidcAuthz](#oidcauthz) + +| Field | Description | +| --- | --- | +| `name` _string_ | Name of the ConfigMap containing the CA certificate. | +| `key` _string_ | Key within the ConfigMap that holds the CA certificate in PEM format. +Defaults to "ca-bundle.crt" if omitted. | #### OnlineStore diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 813428e92d9..2d3fbb37fd4 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -132,6 +132,9 @@ func getBaseServiceRepoConfig( if oidcAuthz.VerifySSL != nil { oidcParameters[string(OidcVerifySsl)] = *oidcAuthz.VerifySSL } + if caCertPath := resolveOidcCACertPath(oidcAuthz); caCertPath != "" { + oidcParameters[string(OidcCaCertPath)] = caCertPath + } repoConfig.AuthzConfig.OidcParameters = oidcParameters } else { repoConfig.AuthzConfig = clientRepoConfig.AuthzConfig @@ -165,6 +168,18 @@ func issuerToDiscoveryUrl(issuerUrl string) string { return strings.TrimRight(issuerUrl, "/") + "/.well-known/openid-configuration" } +// resolveOidcCACertPath determines the CA cert file path for OIDC provider TLS verification. +// When CRD caCertConfigMap is set, returns the explicit mount path. +// Otherwise returns the ODH auto-detected path (the SDK checks os.path.exists at runtime). +func resolveOidcCACertPath(oidcAuthz *feastdevv1.OidcAuthz) string { + if oidcAuthz.CACertConfigMap != nil { + return tlsPathOidcCA + } + // ODH/RHOAI: odh-ca-bundle.crt is mounted by mountCustomCABundle() when the ConfigMap exists. + // On non-ODH clusters the file won't exist and the SDK falls back to system CA. + return tlsPathOdhCABundle +} + func setRepoConfigRegistry(services *feastdevv1.FeatureStoreServices, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), repoConfig *RepoConfig) error { registryPersistence := services.Registry.Local.Persistence diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index 3f851f5eef8..5b4479698f3 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -50,6 +50,11 @@ const ( caBundleAnnotation = "config.openshift.io/inject-trusted-cabundle" caBundleName = "odh-trusted-ca-bundle" + odhCaBundleKey = "odh-ca-bundle.crt" + tlsPathOdhCABundle = "/etc/pki/tls/custom-certs/odh-ca-bundle.crt" + tlsPathOidcCA = "/etc/pki/tls/oidc-ca/ca.crt" + oidcCaVolumeName = "oidc-ca-cert" + defaultCACertKey = "ca-bundle.crt" DefaultOfflineStorageRequest = "20Gi" DefaultOnlineStorageRequest = "5Gi" @@ -93,6 +98,7 @@ const ( OidcPassword OidcPropertyType = "password" OidcTokenEnvVar OidcPropertyType = "token_env_var" OidcVerifySsl OidcPropertyType = "verify_ssl" + OidcCaCertPath OidcPropertyType = "ca_cert_path" OidcMissingSecretError string = "missing OIDC secret: %s" ) diff --git a/infra/feast-operator/internal/controller/services/tls.go b/infra/feast-operator/internal/controller/services/tls.go index a2224d923ee..cd1121f770c 100644 --- a/infra/feast-operator/internal/controller/services/tls.go +++ b/infra/feast-operator/internal/controller/services/tls.go @@ -210,6 +210,10 @@ func (feast *FeastServices) mountTlsConfigs(podSpec *corev1.PodSpec) { feast.mountTlsConfig(OnlineFeastType, podSpec) feast.mountTlsConfig(UIFeastType, podSpec) feast.mountCustomCABundle(podSpec) + appliedSpec := feast.Handler.FeatureStore.Status.Applied + if appliedSpec.AuthzConfig != nil && appliedSpec.AuthzConfig.OidcAuthz != nil { + feast.mountOidcCACert(podSpec, appliedSpec.AuthzConfig.OidcAuthz) + } } func (feast *FeastServices) mountTlsConfig(feastType FeastServiceType, podSpec *corev1.PodSpec) { @@ -281,17 +285,58 @@ func (feast *FeastServices) mountCustomCABundle(podSpec *corev1.PodSpec) { ReadOnly: true, SubPath: "ca-bundle.crt", } + odhCaMount := corev1.VolumeMount{ + Name: customCaBundle.VolumeName, + MountPath: tlsPathOdhCABundle, + ReadOnly: true, + SubPath: odhCaBundleKey, + } for i := range podSpec.Containers { - podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, caMount) + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, caMount, odhCaMount) } for i := range podSpec.InitContainers { - podSpec.InitContainers[i].VolumeMounts = append(podSpec.InitContainers[i].VolumeMounts, caMount) + podSpec.InitContainers[i].VolumeMounts = append(podSpec.InitContainers[i].VolumeMounts, caMount, odhCaMount) } log.FromContext(feast.Handler.Context).Info("Mounted custom CA bundle ConfigMap to Feast pods.") } } +func (feast *FeastServices) mountOidcCACert(podSpec *corev1.PodSpec, oidcAuthz *feastdevv1.OidcAuthz) { + if oidcAuthz.CACertConfigMap == nil { + return + } + cmName := oidcAuthz.CACertConfigMap.Name + cmKey := oidcAuthz.CACertConfigMap.Key + if cmKey == "" { + cmKey = defaultCACertKey + } + + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: oidcCaVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }, + }) + + mount := corev1.VolumeMount{ + Name: oidcCaVolumeName, + MountPath: tlsPathOidcCA, + ReadOnly: true, + SubPath: cmKey, + } + for i := range podSpec.Containers { + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, mount) + } + for i := range podSpec.InitContainers { + podSpec.InitContainers[i].VolumeMounts = append(podSpec.InitContainers[i].VolumeMounts, mount) + } + + log.FromContext(feast.Handler.Context).Info("Mounted OIDC CA certificate ConfigMap to Feast pods.", "configMap", cmName, "key", cmKey) +} + // GetCustomCertificatesBundle retrieves the custom CA bundle ConfigMap if it exists when deployed with RHOAI or ODH func (feast *FeastServices) GetCustomCertificatesBundle() CustomCertificatesBundle { var customCertificatesBundle CustomCertificatesBundle diff --git a/infra/feast-operator/internal/controller/services/util.go b/infra/feast-operator/internal/controller/services/util.go index 19697d749ba..ecf97f2f865 100644 --- a/infra/feast-operator/internal/controller/services/util.go +++ b/infra/feast-operator/internal/controller/services/util.go @@ -407,7 +407,6 @@ func SetIsOpenShift(cfg *rest.Config) { } } - // getEnvVar returns the position of the EnvVar found by name func getEnvVar(envName string, env []corev1.EnvVar) int { for pos, v := range env { diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 3fb87de345a..fe1472f59ca 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -38,6 +38,7 @@ def __init__(self, auth_config: OidcAuthConfig): self.oidc_discovery_service = OIDCDiscoveryService( self._auth_config.auth_discovery_url, verify_ssl=self._auth_config.verify_ssl, + ca_cert_path=self._auth_config.ca_cert_path, ) self._k8s_auth_api = None @@ -99,6 +100,10 @@ def _decode_token(self, access_token: str) -> dict: if not self._auth_config.verify_ssl: ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE + elif self._auth_config.ca_cert_path and os.path.exists( + self._auth_config.ca_cert_path + ): + ssl_ctx.load_verify_locations(self._auth_config.ca_cert_path) jwks_client = PyJWKClient( self.oidc_discovery_service.get_jwks_url(), headers=optional_custom_headers, diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 3ad6da3ebbb..aa38c0d8937 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -38,6 +38,7 @@ class OidcAuthConfig(AuthConfig): auth_discovery_url: str client_id: Optional[str] = None verify_ssl: bool = True + ca_cert_path: str = "" class OidcClientAuthConfig(OidcAuthConfig): diff --git a/sdk/python/feast/permissions/oidc_service.py b/sdk/python/feast/permissions/oidc_service.py index 37a9507102e..48edccd3d44 100644 --- a/sdk/python/feast/permissions/oidc_service.py +++ b/sdk/python/feast/permissions/oidc_service.py @@ -1,10 +1,15 @@ +import os + import requests class OIDCDiscoveryService: - def __init__(self, discovery_url: str, verify_ssl: bool = True): + def __init__( + self, discovery_url: str, verify_ssl: bool = True, ca_cert_path: str = "" + ): self.discovery_url = discovery_url self._verify_ssl = verify_ssl + self._ca_cert_path = ca_cert_path self._discovery_data = None # Initialize it lazily. @property @@ -14,9 +19,16 @@ def discovery_data(self): self._discovery_data = self._fetch_discovery_data() return self._discovery_data + def _get_verify(self): + if not self._verify_ssl: + return False + if self._ca_cert_path and os.path.exists(self._ca_cert_path): + return self._ca_cert_path + return True + def _fetch_discovery_data(self) -> dict: try: - response = requests.get(self.discovery_url, verify=self._verify_ssl) + response = requests.get(self.discovery_url, verify=self._get_verify()) response.raise_for_status() return response.json() except requests.RequestException as e: From a967bc6b3b084df6d648064a354abdfa99e23719 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 03:17:02 +0530 Subject: [PATCH 32/39] Reverted kustomization.yaml to use upstream image Signed-off-by: Aniket Paluskar --- infra/feast-operator/config/manager/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/feast-operator/config/manager/kustomization.yaml b/infra/feast-operator/config/manager/kustomization.yaml index c7a5af8243a..4a0a78531bb 100644 --- a/infra/feast-operator/config/manager/kustomization.yaml +++ b/infra/feast-operator/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: quay.io/aniket-redhat/feast-operator - newTag: oidc-op-0.4 + newName: quay.io/feastdev/feast-operator + newTag: 0.61.0 From c1d7c1179e25021f4f3ddea868957976981ccec4 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 03:37:05 +0530 Subject: [PATCH 33/39] Shorten CRD field descriptions to fit maxDescLen=120 and revert kustomization.yaml to upstream default Signed-off-by: Aniket Paluskar --- .../api/v1/featurestore_types.go | 31 +++------- .../feast-operator.clusterserviceversion.yaml | 2 +- .../manifests/feast.dev_featurestores.yaml | 59 +++++++++---------- .../crd/bases/feast.dev_featurestores.yaml | 59 +++++++++---------- infra/feast-operator/dist/install.yaml | 59 +++++++++---------- infra/feast-operator/docs/api/markdown/ref.md | 31 +++------- 6 files changed, 103 insertions(+), 138 deletions(-) diff --git a/infra/feast-operator/api/v1/featurestore_types.go b/infra/feast-operator/api/v1/featurestore_types.go index eed15db1083..1c2f3aec647 100644 --- a/infra/feast-operator/api/v1/featurestore_types.go +++ b/infra/feast-operator/api/v1/featurestore_types.go @@ -708,45 +708,32 @@ type KubernetesAuthz struct { // OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. // https://auth0.com/docs/authenticate/protocols/openid-connect-protocol type OidcAuthz struct { - // The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). - // The operator derives the OIDC discovery endpoint by appending /.well-known/openid-configuration. - // When set, no Secret is required for basic OIDC authentication. + // OIDC issuer URL. The operator appends /.well-known/openid-configuration to derive the discovery endpoint. // +optional // +kubebuilder:validation:Pattern=`^https://\S+$` IssuerUrl string `json:"issuerUrl,omitempty"` - // Reference to a Secret containing OIDC properties (auth_discovery_url, client_id, client_secret, etc.). - // When both issuerUrl and a Secret with auth_discovery_url are provided, issuerUrl takes precedence. + // Secret with OIDC properties (auth_discovery_url, client_id, client_secret). issuerUrl takes precedence. // +optional SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` - // The key within the Secret that contains the OIDC configuration as a YAML-encoded value. - // When set, only this key is read and its YAML value is expected to contain the OIDC properties - // (e.g. client_id, auth_discovery_url). This allows sharing a single Secret across services. - // When unset, each top-level key in the Secret is treated as a separate OIDC property. + // Key in the Secret containing all OIDC properties as a YAML value. If unset, each key is a property. // +optional SecretKeyName string `json:"secretKeyName,omitempty"` - // The name of the environment variable that Feast SDK client pods (e.g. workbenches, application pods) - // will read a pre-existing OIDC token from. - // When set, the client feature_store.yaml will include token_env_var with this value. - // When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. + // Env var name for client pods to read an OIDC token from. Sets token_env_var in client config. // +optional TokenEnvVar *string `json:"tokenEnvVar,omitempty"` - // Whether to verify SSL certificates when communicating with the OIDC provider. - // Defaults to true. Set to false for self-signed certificates (common in internal OpenShift clusters). + // Verify SSL certificates for the OIDC provider. Defaults to true. // +optional VerifySSL *bool `json:"verifySSL,omitempty"` - // Reference to a ConfigMap containing the CA certificate for the OIDC provider. - // Used when the OIDC provider uses self-signed or custom CA certificates and verifySSL is true. - // On RHOAI/ODH clusters, the operator auto-detects the platform CA bundle; this field is not required. + // ConfigMap with the CA certificate for self-signed OIDC providers. Auto-detected on RHOAI/ODH. // +optional CACertConfigMap *OidcCACertConfigMap `json:"caCertConfigMap,omitempty"` } -// OidcCACertConfigMap references a ConfigMap containing a CA certificate for OIDC provider TLS verification. +// OidcCACertConfigMap references a ConfigMap containing a CA certificate for OIDC provider TLS. type OidcCACertConfigMap struct { - // Name of the ConfigMap containing the CA certificate. + // ConfigMap name. Name string `json:"name"` - // Key within the ConfigMap that holds the CA certificate in PEM format. - // Defaults to "ca-bundle.crt" if omitted. + // Key in the ConfigMap holding the PEM certificate. Defaults to "ca-bundle.crt". // +optional Key string `json:"key,omitempty"` } diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index aa1cd0bb3a2..a1664fde178 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -50,7 +50,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-04-06T19:35:43Z" + createdAt: "2026-04-06T22:05:44Z" operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.61.0 diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 3a5c21702c5..c80d57e1014 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -63,31 +63,31 @@ spec: https://auth0. properties: caCertConfigMap: - description: Reference to a ConfigMap containing the CA certificate - for the OIDC provider. + description: ConfigMap with the CA certificate for self-signed + OIDC providers. Auto-detected on RHOAI/ODH. properties: key: - description: |- - Key within the ConfigMap that holds the CA certificate in PEM format. - Defaults to "ca-bundle.crt" if omitted. + description: Key in the ConfigMap holding the PEM certificate. + Defaults to "ca-bundle.crt". type: string name: - description: Name of the ConfigMap containing the CA certificate. + description: ConfigMap name. type: string required: - name type: object issuerUrl: - description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + description: OIDC issuer URL. The operator appends /.well-known/openid-configuration + to derive the discovery endpoint. pattern: ^https://\S+$ type: string secretKeyName: - description: The key within the Secret that contains the OIDC - configuration as a YAML-encoded value. + description: Key in the Secret containing all OIDC properties + as a YAML value. If unset, each key is a property. type: string secretRef: - description: Reference to a Secret containing OIDC properties - (auth_discovery_url, client_id, client_secret, etc.). + description: Secret with OIDC properties (auth_discovery_url, + client_id, client_secret). issuerUrl takes precedence. properties: name: default: "" @@ -99,12 +99,11 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that Feast - SDK client pods (e.g. + description: Env var name for client pods to read an OIDC + token from. Sets token_env_var in client config. type: string verifySSL: - description: |- - Whether to verify SSL certificates when communicating with the OIDC provider. + description: Verify SSL certificates for the OIDC provider. Defaults to true. type: boolean type: object @@ -5787,32 +5786,31 @@ spec: https://auth0. properties: caCertConfigMap: - description: Reference to a ConfigMap containing the CA - certificate for the OIDC provider. + description: ConfigMap with the CA certificate for self-signed + OIDC providers. Auto-detected on RHOAI/ODH. properties: key: - description: |- - Key within the ConfigMap that holds the CA certificate in PEM format. - Defaults to "ca-bundle.crt" if omitted. + description: Key in the ConfigMap holding the PEM + certificate. Defaults to "ca-bundle.crt". type: string name: - description: Name of the ConfigMap containing the - CA certificate. + description: ConfigMap name. type: string required: - name type: object issuerUrl: - description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + description: OIDC issuer URL. The operator appends /.well-known/openid-configuration + to derive the discovery endpoint. pattern: ^https://\S+$ type: string secretKeyName: - description: The key within the Secret that contains the - OIDC configuration as a YAML-encoded value. + description: Key in the Secret containing all OIDC properties + as a YAML value. If unset, each key is a property. type: string secretRef: - description: Reference to a Secret containing OIDC properties - (auth_discovery_url, client_id, client_secret, etc.). + description: Secret with OIDC properties (auth_discovery_url, + client_id, client_secret). issuerUrl takes precedence. properties: name: default: "" @@ -5824,12 +5822,11 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that - Feast SDK client pods (e.g. + description: Env var name for client pods to read an OIDC + token from. Sets token_env_var in client config. type: string verifySSL: - description: |- - Whether to verify SSL certificates when communicating with the OIDC provider. + description: Verify SSL certificates for the OIDC provider. Defaults to true. type: boolean type: object diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index c87d99ee38b..686818f645e 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -63,31 +63,31 @@ spec: https://auth0. properties: caCertConfigMap: - description: Reference to a ConfigMap containing the CA certificate - for the OIDC provider. + description: ConfigMap with the CA certificate for self-signed + OIDC providers. Auto-detected on RHOAI/ODH. properties: key: - description: |- - Key within the ConfigMap that holds the CA certificate in PEM format. - Defaults to "ca-bundle.crt" if omitted. + description: Key in the ConfigMap holding the PEM certificate. + Defaults to "ca-bundle.crt". type: string name: - description: Name of the ConfigMap containing the CA certificate. + description: ConfigMap name. type: string required: - name type: object issuerUrl: - description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + description: OIDC issuer URL. The operator appends /.well-known/openid-configuration + to derive the discovery endpoint. pattern: ^https://\S+$ type: string secretKeyName: - description: The key within the Secret that contains the OIDC - configuration as a YAML-encoded value. + description: Key in the Secret containing all OIDC properties + as a YAML value. If unset, each key is a property. type: string secretRef: - description: Reference to a Secret containing OIDC properties - (auth_discovery_url, client_id, client_secret, etc.). + description: Secret with OIDC properties (auth_discovery_url, + client_id, client_secret). issuerUrl takes precedence. properties: name: default: "" @@ -99,12 +99,11 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that Feast - SDK client pods (e.g. + description: Env var name for client pods to read an OIDC + token from. Sets token_env_var in client config. type: string verifySSL: - description: |- - Whether to verify SSL certificates when communicating with the OIDC provider. + description: Verify SSL certificates for the OIDC provider. Defaults to true. type: boolean type: object @@ -5787,32 +5786,31 @@ spec: https://auth0. properties: caCertConfigMap: - description: Reference to a ConfigMap containing the CA - certificate for the OIDC provider. + description: ConfigMap with the CA certificate for self-signed + OIDC providers. Auto-detected on RHOAI/ODH. properties: key: - description: |- - Key within the ConfigMap that holds the CA certificate in PEM format. - Defaults to "ca-bundle.crt" if omitted. + description: Key in the ConfigMap holding the PEM + certificate. Defaults to "ca-bundle.crt". type: string name: - description: Name of the ConfigMap containing the - CA certificate. + description: ConfigMap name. type: string required: - name type: object issuerUrl: - description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + description: OIDC issuer URL. The operator appends /.well-known/openid-configuration + to derive the discovery endpoint. pattern: ^https://\S+$ type: string secretKeyName: - description: The key within the Secret that contains the - OIDC configuration as a YAML-encoded value. + description: Key in the Secret containing all OIDC properties + as a YAML value. If unset, each key is a property. type: string secretRef: - description: Reference to a Secret containing OIDC properties - (auth_discovery_url, client_id, client_secret, etc.). + description: Secret with OIDC properties (auth_discovery_url, + client_id, client_secret). issuerUrl takes precedence. properties: name: default: "" @@ -5824,12 +5822,11 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that - Feast SDK client pods (e.g. + description: Env var name for client pods to read an OIDC + token from. Sets token_env_var in client config. type: string verifySSL: - description: |- - Whether to verify SSL certificates when communicating with the OIDC provider. + description: Verify SSL certificates for the OIDC provider. Defaults to true. type: boolean type: object diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index 0795e4e0035..fc7a19bba7e 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -71,31 +71,31 @@ spec: https://auth0. properties: caCertConfigMap: - description: Reference to a ConfigMap containing the CA certificate - for the OIDC provider. + description: ConfigMap with the CA certificate for self-signed + OIDC providers. Auto-detected on RHOAI/ODH. properties: key: - description: |- - Key within the ConfigMap that holds the CA certificate in PEM format. - Defaults to "ca-bundle.crt" if omitted. + description: Key in the ConfigMap holding the PEM certificate. + Defaults to "ca-bundle.crt". type: string name: - description: Name of the ConfigMap containing the CA certificate. + description: ConfigMap name. type: string required: - name type: object issuerUrl: - description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + description: OIDC issuer URL. The operator appends /.well-known/openid-configuration + to derive the discovery endpoint. pattern: ^https://\S+$ type: string secretKeyName: - description: The key within the Secret that contains the OIDC - configuration as a YAML-encoded value. + description: Key in the Secret containing all OIDC properties + as a YAML value. If unset, each key is a property. type: string secretRef: - description: Reference to a Secret containing OIDC properties - (auth_discovery_url, client_id, client_secret, etc.). + description: Secret with OIDC properties (auth_discovery_url, + client_id, client_secret). issuerUrl takes precedence. properties: name: default: "" @@ -107,12 +107,11 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that Feast - SDK client pods (e.g. + description: Env var name for client pods to read an OIDC + token from. Sets token_env_var in client config. type: string verifySSL: - description: |- - Whether to verify SSL certificates when communicating with the OIDC provider. + description: Verify SSL certificates for the OIDC provider. Defaults to true. type: boolean type: object @@ -5795,32 +5794,31 @@ spec: https://auth0. properties: caCertConfigMap: - description: Reference to a ConfigMap containing the CA - certificate for the OIDC provider. + description: ConfigMap with the CA certificate for self-signed + OIDC providers. Auto-detected on RHOAI/ODH. properties: key: - description: |- - Key within the ConfigMap that holds the CA certificate in PEM format. - Defaults to "ca-bundle.crt" if omitted. + description: Key in the ConfigMap holding the PEM + certificate. Defaults to "ca-bundle.crt". type: string name: - description: Name of the ConfigMap containing the - CA certificate. + description: ConfigMap name. type: string required: - name type: object issuerUrl: - description: The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). + description: OIDC issuer URL. The operator appends /.well-known/openid-configuration + to derive the discovery endpoint. pattern: ^https://\S+$ type: string secretKeyName: - description: The key within the Secret that contains the - OIDC configuration as a YAML-encoded value. + description: Key in the Secret containing all OIDC properties + as a YAML value. If unset, each key is a property. type: string secretRef: - description: Reference to a Secret containing OIDC properties - (auth_discovery_url, client_id, client_secret, etc.). + description: Secret with OIDC properties (auth_discovery_url, + client_id, client_secret). issuerUrl takes precedence. properties: name: default: "" @@ -5832,12 +5830,11 @@ spec: type: object x-kubernetes-map-type: atomic tokenEnvVar: - description: The name of the environment variable that - Feast SDK client pods (e.g. + description: Env var name for client pods to read an OIDC + token from. Sets token_env_var in client config. type: string verifySSL: - description: |- - Whether to verify SSL certificates when communicating with the OIDC provider. + description: Verify SSL certificates for the OIDC provider. Defaults to true. type: boolean type: object diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index 77276c4cc70..59385a3688f 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -542,40 +542,27 @@ _Appears in:_ | Field | Description | | --- | --- | -| `issuerUrl` _string_ | The OIDC issuer URL (e.g. "https://keycloak.example.com/realms/myrealm"). -The operator derives the OIDC discovery endpoint by appending /.well-known/openid-configuration. -When set, no Secret is required for basic OIDC authentication. | -| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Reference to a Secret containing OIDC properties (auth_discovery_url, client_id, client_secret, etc.). -When both issuerUrl and a Secret with auth_discovery_url are provided, issuerUrl takes precedence. | -| `secretKeyName` _string_ | The key within the Secret that contains the OIDC configuration as a YAML-encoded value. -When set, only this key is read and its YAML value is expected to contain the OIDC properties -(e.g. client_id, auth_discovery_url). This allows sharing a single Secret across services. -When unset, each top-level key in the Secret is treated as a separate OIDC property. | -| `tokenEnvVar` _string_ | The name of the environment variable that Feast SDK client pods (e.g. workbenches, application pods) -will read a pre-existing OIDC token from. -When set, the client feature_store.yaml will include token_env_var with this value. -When unset, the client config is bare `type: oidc` which falls back to FEAST_OIDC_TOKEN or the pod's SA token. | -| `verifySSL` _boolean_ | Whether to verify SSL certificates when communicating with the OIDC provider. -Defaults to true. Set to false for self-signed certificates (common in internal OpenShift clusters). | -| `caCertConfigMap` _[OidcCACertConfigMap](#oidccacertconfigmap)_ | Reference to a ConfigMap containing the CA certificate for the OIDC provider. -Used when the OIDC provider uses self-signed or custom CA certificates and verifySSL is true. -On RHOAI/ODH clusters, the operator auto-detects the platform CA bundle; this field is not required. | +| `issuerUrl` _string_ | OIDC issuer URL. The operator appends /.well-known/openid-configuration to derive the discovery endpoint. | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Secret with OIDC properties (auth_discovery_url, client_id, client_secret). issuerUrl takes precedence. | +| `secretKeyName` _string_ | Key in the Secret containing all OIDC properties as a YAML value. If unset, each key is a property. | +| `tokenEnvVar` _string_ | Env var name for client pods to read an OIDC token from. Sets token_env_var in client config. | +| `verifySSL` _boolean_ | Verify SSL certificates for the OIDC provider. Defaults to true. | +| `caCertConfigMap` _[OidcCACertConfigMap](#oidccacertconfigmap)_ | ConfigMap with the CA certificate for self-signed OIDC providers. Auto-detected on RHOAI/ODH. | #### OidcCACertConfigMap -OidcCACertConfigMap references a ConfigMap containing a CA certificate for OIDC provider TLS verification. +OidcCACertConfigMap references a ConfigMap containing a CA certificate for OIDC provider TLS. _Appears in:_ - [OidcAuthz](#oidcauthz) | Field | Description | | --- | --- | -| `name` _string_ | Name of the ConfigMap containing the CA certificate. | -| `key` _string_ | Key within the ConfigMap that holds the CA certificate in PEM format. -Defaults to "ca-bundle.crt" if omitted. | +| `name` _string_ | ConfigMap name. | +| `key` _string_ | Key in the ConfigMap holding the PEM certificate. Defaults to "ca-bundle.crt". | #### OnlineStore From 8aae62ae5145a759898116917d393c8f1d4bfc87 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 14:58:40 +0530 Subject: [PATCH 34/39] fix: Remove unused param, nil deref in test, and update secrets baseline Signed-off-by: Aniket Paluskar --- .secrets.baseline | 12 ++++++------ .../internal/controller/services/repo_config.go | 6 ++---- .../internal/controller/services/repo_config_test.go | 1 - 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 510ca5cad38..9dc1a55c7f8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -934,7 +934,7 @@ "filename": "infra/feast-operator/api/v1/featurestore_types.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 729 + "line_number": 756 } ], "infra/feast-operator/api/v1/zz_generated.deepcopy.go": [ @@ -950,14 +950,14 @@ "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 1254 + "line_number": 747 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/api/v1/zz_generated.deepcopy.go", "hashed_secret": "c2028031c154bbe86fd69bef740855c74b927dcf", "is_verified": false, - "line_number": 1259 + "line_number": 1293 } ], "infra/feast-operator/api/v1alpha1/featurestore_types.go": [ @@ -1140,14 +1140,14 @@ "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 109 + "line_number": 114 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "e2fb052132fd6a07a56af2013e0b62a1f510572c", "is_verified": false, - "line_number": 148 + "line_number": 204 } ], "infra/feast-operator/internal/controller/services/services.go": [ @@ -1539,5 +1539,5 @@ } ] }, - "generated_at": "2026-03-18T13:51:43Z" + "generated_at": "2026-04-07T09:25:31Z" } diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 2d3fbb37fd4..3143f8d0b2c 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -354,7 +354,7 @@ func getClientRepoConfig( feast *FeastServices) (RepoConfig, error) { status := featureStore.Status appliedServices := status.Applied.Services - clientRepoConfig, err := getRepoConfig(featureStore, secretExtractionFunc) + clientRepoConfig, err := getRepoConfig(featureStore) if err != nil { return clientRepoConfig, err } @@ -398,9 +398,7 @@ func getClientRepoConfig( return clientRepoConfig, nil } -func getRepoConfig( - featureStore *feastdevv1.FeatureStore, - secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { +func getRepoConfig(featureStore *feastdevv1.FeatureStore) (RepoConfig, error) { status := featureStore.Status repoConfig := initRepoConfig(status.Applied.FeastProject) if status.Applied.AuthzConfig != nil { 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 45d08d86631..f8e7ffa44eb 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -370,7 +370,6 @@ var _ = Describe("Repo Config", func() { Expect(err).NotTo(HaveOccurred()) _, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) Expect(err).NotTo(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) }) }) From cacd6493a31b02573bf986a66f6b28cdf744eea3 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 16:33:31 +0530 Subject: [PATCH 35/39] fix: Remove unused secretExtractionFunc from client config chain and fix mypy attr-defined error Signed-off-by: Aniket Paluskar --- .../internal/controller/services/client.go | 2 +- .../internal/controller/services/repo_config.go | 16 ++++++---------- .../controller/services/repo_config_test.go | 8 ++++---- .../internal/controller/services/tls_test.go | 4 ++-- .../feast/permissions/auth/oidc_token_parser.py | 3 ++- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/infra/feast-operator/internal/controller/services/client.go b/infra/feast-operator/internal/controller/services/client.go index fbd972368fb..6ce01ed0cc2 100644 --- a/infra/feast-operator/internal/controller/services/client.go +++ b/infra/feast-operator/internal/controller/services/client.go @@ -47,7 +47,7 @@ func (feast *FeastServices) createClientConfigMap() error { func (feast *FeastServices) setClientConfigMap(cm *corev1.ConfigMap) error { cm.Labels = feast.getFeastTypeLabels(ClientFeastType) - clientYaml, err := feast.getClientFeatureStoreYaml(feast.extractConfigFromSecret) + clientYaml, err := feast.getClientFeatureStoreYaml() if err != nil { return err } diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 3143f8d0b2c..0741314c635 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -97,7 +97,7 @@ func getBaseServiceRepoConfig( secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { repoConfig := defaultRepoConfig(featureStore) - clientRepoConfig, err := getClientRepoConfig(featureStore, secretExtractionFunc, nil) + clientRepoConfig, err := getClientRepoConfig(featureStore, nil) if err != nil { return repoConfig, err } @@ -340,8 +340,8 @@ func setRepoConfigBatchEngine( return nil } -func (feast *FeastServices) getClientFeatureStoreYaml(secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) ([]byte, error) { - clientRepo, err := getClientRepoConfig(feast.Handler.FeatureStore, secretExtractionFunc, feast) +func (feast *FeastServices) getClientFeatureStoreYaml() ([]byte, error) { + clientRepo, err := getClientRepoConfig(feast.Handler.FeatureStore, feast) if err != nil { return []byte{}, err } @@ -350,14 +350,10 @@ func (feast *FeastServices) getClientFeatureStoreYaml(secretExtractionFunc func( func getClientRepoConfig( featureStore *feastdevv1.FeatureStore, - secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), feast *FeastServices) (RepoConfig, error) { status := featureStore.Status appliedServices := status.Applied.Services - clientRepoConfig, err := getRepoConfig(featureStore) - if err != nil { - return clientRepoConfig, err - } + clientRepoConfig := getRepoConfig(featureStore) if len(status.ServiceHostnames.OfflineStore) > 0 { clientRepoConfig.OfflineStore = OfflineStoreConfig{ Type: OfflineRemoteConfigType, @@ -398,7 +394,7 @@ func getClientRepoConfig( return clientRepoConfig, nil } -func getRepoConfig(featureStore *feastdevv1.FeatureStore) (RepoConfig, error) { +func getRepoConfig(featureStore *feastdevv1.FeatureStore) RepoConfig { status := featureStore.Status repoConfig := initRepoConfig(status.Applied.FeastProject) if status.Applied.AuthzConfig != nil { @@ -419,7 +415,7 @@ func getRepoConfig(featureStore *feastdevv1.FeatureStore) (RepoConfig, error) { } } } - return repoConfig, nil + return repoConfig } func getActualPath(filePath string, pvcConfig *feastdevv1.PvcConfig) string { 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 f8e7ffa44eb..7e92ba6608a 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -224,7 +224,7 @@ var _ = Describe("Repo Config", func() { Expect(repoConfig.OnlineStore).To(Equal(defaultOnlineStoreConfig(featureStore))) Expect(repoConfig.Registry).To(Equal(defaultRegistryConfig(featureStore))) - repoConfig, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) + repoConfig, err = getClientRepoConfig(featureStore, nil) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) @@ -368,7 +368,7 @@ var _ = Describe("Repo Config", func() { string(OidcPassword): "password"}) _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) Expect(err).NotTo(HaveOccurred()) - _, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) + _, err = getClientRepoConfig(featureStore, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -545,7 +545,7 @@ var _ = Describe("TLS Certificate Path Configuration", func() { } // Test with nil feast parameter (no custom CA bundle) - repoConfig, err := getClientRepoConfig(featureStore, emptyMockExtractConfigFromSecret, nil) + repoConfig, err := getClientRepoConfig(featureStore, nil) Expect(err).NotTo(HaveOccurred()) // Verify individual service certificate paths are used @@ -615,7 +615,7 @@ var _ = Describe("TLS Certificate Path Configuration", func() { } // Test with nil feast parameter (no custom CA bundle available) - repoConfig, err := getClientRepoConfig(featureStore, emptyMockExtractConfigFromSecret, nil) + repoConfig, err := getClientRepoConfig(featureStore, nil) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.OfflineStore.Cert).To(Equal("/tls/offline/tls.crt")) }) diff --git a/infra/feast-operator/internal/controller/services/tls_test.go b/infra/feast-operator/internal/controller/services/tls_test.go index b59ef80181e..5961bd77c30 100644 --- a/infra/feast-operator/internal/controller/services/tls_test.go +++ b/infra/feast-operator/internal/controller/services/tls_test.go @@ -128,7 +128,7 @@ var _ = Describe("TLS Config", func() { err = feast.ApplyDefaults() Expect(err).ToNot(HaveOccurred()) - repoConfig, err := getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret, &feast) + repoConfig, err := getClientRepoConfig(feast.Handler.FeatureStore, &feast) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) @@ -275,7 +275,7 @@ var _ = Describe("TLS Config", func() { err = feast.ApplyDefaults() Expect(err).ToNot(HaveOccurred()) - repoConfig, err = getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret, &feast) + repoConfig, err = getClientRepoConfig(feast.Handler.FeatureStore, &feast) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index fe1472f59ca..2b67be2e6e2 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -211,7 +211,8 @@ async def _validate_k8s_sa_token_and_extract_namespace( token_review = client.V1TokenReview( spec=client.V1TokenReviewSpec(token=access_token) ) - response = self._k8s_auth_api.create_token_review(token_review) + auth_api: client.AuthenticationV1Api = self._k8s_auth_api + response = auth_api.create_token_review(token_review) if not response.status.authenticated: raise AuthenticationError( From 141c87160084f5cbe42b108673499c35cfcdaf4a Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 16:55:43 +0530 Subject: [PATCH 36/39] Remove always-nil error from getClientRepoConfig and stop leaking ODH CA path into non-ODH OIDC config Signed-off-by: Aniket Paluskar --- .../controller/services/repo_config.go | 23 +++++++------------ .../controller/services/repo_config_test.go | 12 ++++------ .../internal/controller/services/tls_test.go | 6 ++--- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 0741314c635..bd051e553e6 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -97,10 +97,7 @@ func getBaseServiceRepoConfig( secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { repoConfig := defaultRepoConfig(featureStore) - clientRepoConfig, err := getClientRepoConfig(featureStore, nil) - if err != nil { - return repoConfig, err - } + clientRepoConfig := getClientRepoConfig(featureStore, nil) if isRemoteRegistry(featureStore) { repoConfig.Registry = clientRepoConfig.Registry } @@ -112,6 +109,7 @@ func getBaseServiceRepoConfig( var secretProperties map[string]interface{} if oidcAuthz.SecretRef != nil { + var err error secretProperties, err = secretExtractionFunc("", oidcAuthz.SecretRef.Name, oidcAuthz.SecretKeyName) if err != nil { return repoConfig, err @@ -169,15 +167,13 @@ func issuerToDiscoveryUrl(issuerUrl string) string { } // resolveOidcCACertPath determines the CA cert file path for OIDC provider TLS verification. -// When CRD caCertConfigMap is set, returns the explicit mount path. -// Otherwise returns the ODH auto-detected path (the SDK checks os.path.exists at runtime). +// Returns the explicit mount path only when CRD caCertConfigMap is set. +// On ODH/RHOAI clusters, users should set caCertConfigMap pointing to odh-trusted-ca-bundle. func resolveOidcCACertPath(oidcAuthz *feastdevv1.OidcAuthz) string { if oidcAuthz.CACertConfigMap != nil { return tlsPathOidcCA } - // ODH/RHOAI: odh-ca-bundle.crt is mounted by mountCustomCABundle() when the ConfigMap exists. - // On non-ODH clusters the file won't exist and the SDK falls back to system CA. - return tlsPathOdhCABundle + return "" } func setRepoConfigRegistry(services *feastdevv1.FeatureStoreServices, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), repoConfig *RepoConfig) error { @@ -341,16 +337,13 @@ func setRepoConfigBatchEngine( } func (feast *FeastServices) getClientFeatureStoreYaml() ([]byte, error) { - clientRepo, err := getClientRepoConfig(feast.Handler.FeatureStore, feast) - if err != nil { - return []byte{}, err - } + clientRepo := getClientRepoConfig(feast.Handler.FeatureStore, feast) return yaml.Marshal(clientRepo) } func getClientRepoConfig( featureStore *feastdevv1.FeatureStore, - feast *FeastServices) (RepoConfig, error) { + feast *FeastServices) RepoConfig { status := featureStore.Status appliedServices := status.Applied.Services clientRepoConfig := getRepoConfig(featureStore) @@ -391,7 +384,7 @@ func getClientRepoConfig( } } - return clientRepoConfig, nil + return clientRepoConfig } func getRepoConfig(featureStore *feastdevv1.FeatureStore) RepoConfig { 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 7e92ba6608a..0f32c5edc1e 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -224,8 +224,7 @@ var _ = Describe("Repo Config", func() { Expect(repoConfig.OnlineStore).To(Equal(defaultOnlineStoreConfig(featureStore))) Expect(repoConfig.Registry).To(Equal(defaultRegistryConfig(featureStore))) - repoConfig, err = getClientRepoConfig(featureStore, nil) - Expect(err).NotTo(HaveOccurred()) + repoConfig = getClientRepoConfig(featureStore, nil) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) By("Having oidc authorization with issuerUrl only (no Secret)") @@ -368,8 +367,7 @@ var _ = Describe("Repo Config", func() { string(OidcPassword): "password"}) _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) Expect(err).NotTo(HaveOccurred()) - _, err = getClientRepoConfig(featureStore, nil) - Expect(err).NotTo(HaveOccurred()) + getClientRepoConfig(featureStore, nil) }) }) @@ -545,8 +543,7 @@ var _ = Describe("TLS Certificate Path Configuration", func() { } // Test with nil feast parameter (no custom CA bundle) - repoConfig, err := getClientRepoConfig(featureStore, nil) - Expect(err).NotTo(HaveOccurred()) + repoConfig := getClientRepoConfig(featureStore, nil) // Verify individual service certificate paths are used Expect(repoConfig.OfflineStore.Cert).To(Equal("/tls/offline/tls.crt")) @@ -615,8 +612,7 @@ var _ = Describe("TLS Certificate Path Configuration", func() { } // Test with nil feast parameter (no custom CA bundle available) - repoConfig, err := getClientRepoConfig(featureStore, nil) - Expect(err).NotTo(HaveOccurred()) + repoConfig := getClientRepoConfig(featureStore, nil) Expect(repoConfig.OfflineStore.Cert).To(Equal("/tls/offline/tls.crt")) }) }) diff --git a/infra/feast-operator/internal/controller/services/tls_test.go b/infra/feast-operator/internal/controller/services/tls_test.go index 5961bd77c30..0bd3bb82694 100644 --- a/infra/feast-operator/internal/controller/services/tls_test.go +++ b/infra/feast-operator/internal/controller/services/tls_test.go @@ -128,8 +128,7 @@ var _ = Describe("TLS Config", func() { err = feast.ApplyDefaults() Expect(err).ToNot(HaveOccurred()) - repoConfig, err := getClientRepoConfig(feast.Handler.FeatureStore, &feast) - Expect(err).NotTo(HaveOccurred()) + repoConfig := getClientRepoConfig(feast.Handler.FeatureStore, &feast) Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) Expect(repoConfig.OfflineStore.Cert).To(ContainSubstring(string(OfflineFeastType))) @@ -275,8 +274,7 @@ var _ = Describe("TLS Config", func() { err = feast.ApplyDefaults() Expect(err).ToNot(HaveOccurred()) - repoConfig, err = getClientRepoConfig(feast.Handler.FeatureStore, &feast) - Expect(err).NotTo(HaveOccurred()) + repoConfig = getClientRepoConfig(feast.Handler.FeatureStore, &feast) Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) Expect(repoConfig.OfflineStore.Cert).To(ContainSubstring(string(OfflineFeastType))) From bd81904dc7ada76a7c5fa260829aa9f75b2c9edc Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 17:36:39 +0530 Subject: [PATCH 37/39] Remove always-nil error from getClientRepoConfig, stop leaking ODH CA path, pass ca_cert_path to client token fetch, and update secrets baseline Signed-off-by: Aniket Paluskar --- .secrets.baseline | 4 ++-- .../client/oidc_authentication_client_manager.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 9dc1a55c7f8..4c1a29f6eeb 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1140,14 +1140,14 @@ "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 114 + "line_number": 111 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "e2fb052132fd6a07a56af2013e0b62a1f510572c", "is_verified": false, - "line_number": 204 + "line_number": 200 } ], "infra/feast-operator/internal/controller/services/services.go": [ diff --git a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py index cb86f033faa..84a0c0115c9 100644 --- a/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py +++ b/sdk/python/feast/permissions/client/oidc_authentication_client_manager.py @@ -73,10 +73,12 @@ def _fetch_token_from_idp(self) -> str: "auth_discovery_url is required for IDP token fetch " "(client_credentials or ROPG flow)." ) - token_endpoint = OIDCDiscoveryService( + discovery = OIDCDiscoveryService( self.auth_config.auth_discovery_url, verify_ssl=self.auth_config.verify_ssl, - ).get_token_url() + ca_cert_path=self.auth_config.ca_cert_path, + ) + token_endpoint = discovery.get_token_url() if self.auth_config.client_secret and not ( self.auth_config.username and self.auth_config.password @@ -100,7 +102,7 @@ def _fetch_token_from_idp(self) -> str: token_endpoint, data=token_request_body, headers=headers, - verify=self.auth_config.verify_ssl, + verify=discovery._get_verify(), ) if token_response.status_code == 200: From 66c3677005063c55d5934a0945d542495d0e6b3f Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 17:56:31 +0530 Subject: [PATCH 38/39] Thread ODH CA bundle detection into resolveOidcCACertPath for proper 3-tier priority Signed-off-by: Aniket Paluskar --- .secrets.baseline | 4 +-- .../controller/services/repo_config.go | 21 +++++++++------ .../controller/services/repo_config_test.go | 26 +++++++++---------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4c1a29f6eeb..498a62630b1 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1140,14 +1140,14 @@ "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 111 + "line_number": 114 }, { "type": "Secret Keyword", "filename": "infra/feast-operator/internal/controller/services/repo_config.go", "hashed_secret": "e2fb052132fd6a07a56af2013e0b62a1f510572c", "is_verified": false, - "line_number": 200 + "line_number": 205 } ], "infra/feast-operator/internal/controller/services/services.go": [ diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index bd051e553e6..b67d948033b 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -47,14 +47,16 @@ func (feast *FeastServices) getServiceFeatureStoreYaml() ([]byte, error) { } func (feast *FeastServices) getServiceRepoConfig() (RepoConfig, error) { - return getServiceRepoConfig(feast.Handler.FeatureStore, feast.extractConfigFromSecret, feast.extractConfigFromConfigMap) + odhCaBundleExists := feast.GetCustomCertificatesBundle().IsDefined + return getServiceRepoConfig(feast.Handler.FeatureStore, feast.extractConfigFromSecret, feast.extractConfigFromConfigMap, odhCaBundleExists) } func getServiceRepoConfig( featureStore *feastdevv1.FeatureStore, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), - configMapExtractionFunc func(configMapRef string, configMapKey string) (map[string]interface{}, error)) (RepoConfig, error) { - repoConfig, err := getBaseServiceRepoConfig(featureStore, secretExtractionFunc) + configMapExtractionFunc func(configMapRef string, configMapKey string) (map[string]interface{}, error), + odhCaBundleExists bool) (RepoConfig, error) { + repoConfig, err := getBaseServiceRepoConfig(featureStore, secretExtractionFunc, odhCaBundleExists) if err != nil { return repoConfig, err } @@ -94,7 +96,8 @@ func getServiceRepoConfig( func getBaseServiceRepoConfig( featureStore *feastdevv1.FeatureStore, - secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { + secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), + odhCaBundleExists bool) (RepoConfig, error) { repoConfig := defaultRepoConfig(featureStore) clientRepoConfig := getClientRepoConfig(featureStore, nil) @@ -130,7 +133,7 @@ func getBaseServiceRepoConfig( if oidcAuthz.VerifySSL != nil { oidcParameters[string(OidcVerifySsl)] = *oidcAuthz.VerifySSL } - if caCertPath := resolveOidcCACertPath(oidcAuthz); caCertPath != "" { + if caCertPath := resolveOidcCACertPath(oidcAuthz, odhCaBundleExists); caCertPath != "" { oidcParameters[string(OidcCaCertPath)] = caCertPath } repoConfig.AuthzConfig.OidcParameters = oidcParameters @@ -167,12 +170,14 @@ func issuerToDiscoveryUrl(issuerUrl string) string { } // resolveOidcCACertPath determines the CA cert file path for OIDC provider TLS verification. -// Returns the explicit mount path only when CRD caCertConfigMap is set. -// On ODH/RHOAI clusters, users should set caCertConfigMap pointing to odh-trusted-ca-bundle. -func resolveOidcCACertPath(oidcAuthz *feastdevv1.OidcAuthz) string { +// Priority: explicit CRD caCertConfigMap > ODH auto-detected bundle > empty (system CA fallback). +func resolveOidcCACertPath(oidcAuthz *feastdevv1.OidcAuthz, odhCaBundleExists bool) string { if oidcAuthz.CACertConfigMap != nil { return tlsPathOidcCA } + if odhCaBundleExists { + return tlsPathOdhCABundle + } return "" } 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 0f32c5edc1e..20fff934f19 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -46,7 +46,7 @@ var _ = Describe("Repo Config", func() { Path: EphemeralPath + "/" + DefaultOnlineStorePath, } - repoConfig, err := getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err := getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) Expect(repoConfig.OfflineStore).To(Equal(emptyOfflineStoreConfig)) @@ -74,7 +74,7 @@ var _ = Describe("Repo Config", func() { Path: testPath, } - repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) Expect(repoConfig.OfflineStore).To(Equal(emptyOfflineStoreConfig)) @@ -96,7 +96,7 @@ var _ = Describe("Repo Config", func() { Expect(appliedServices.OnlineStore).NotTo(BeNil()) Expect(appliedServices.Registry.Local).NotTo(BeNil()) - repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.OfflineStore).To(Equal(defaultOfflineStoreConfig)) Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) @@ -115,7 +115,7 @@ var _ = Describe("Repo Config", func() { }, } ApplyDefaultsToStatus(featureStore) - repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) Expect(repoConfig.OfflineStore).To(Equal(emptyOfflineStoreConfig)) @@ -163,7 +163,7 @@ var _ = Describe("Repo Config", func() { Path: "/data/online.db", } - repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) @@ -188,7 +188,7 @@ var _ = Describe("Repo Config", func() { Type: "dask", } - repoConfig, err = getServiceRepoConfig(featureStore, mockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, mockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(KubernetesAuthType)) Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) @@ -211,7 +211,7 @@ var _ = Describe("Repo Config", func() { string(OidcClientSecret): "client-secret", string(OidcUsername): "username", string(OidcPassword): "password"}) - repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) + 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)) @@ -234,7 +234,7 @@ var _ = Describe("Repo Config", func() { }, } ApplyDefaultsToStatus(featureStore) - repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(1)) @@ -250,7 +250,7 @@ var _ = Describe("Repo Config", func() { }, } ApplyDefaultsToStatus(featureStore) - repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.OidcParameters[string(OidcAuthDiscoveryUrl)]).To(Equal("https://keycloak.example.com/realms/cr-wins/.well-known/openid-configuration")) @@ -295,7 +295,7 @@ var _ = Describe("Repo Config", func() { featureStore.Spec.Services.OfflineStore.Persistence.FilePersistence = nil featureStore.Spec.Services.OnlineStore.Persistence.FilePersistence = nil featureStore.Spec.Services.Registry.Local.Persistence.FilePersistence = nil - repoConfig, err = getServiceRepoConfig(featureStore, mockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + repoConfig, err = getServiceRepoConfig(featureStore, mockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) newMap := CopyMap(parameterMap) port := parameterMap["port"].(int) @@ -327,7 +327,7 @@ var _ = Describe("Repo Config", func() { } ApplyDefaultsToStatus(featureStore) - _, err := getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap) + _, err := getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret, emptyMockExtractConfigFromConfigMap, false) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no OIDC discovery URL configured")) @@ -346,7 +346,7 @@ var _ = Describe("Repo Config", func() { string(OidcClientSecret): "client-secret", string(OidcUsername): "username", string(OidcPassword): "password"}) - _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap, false) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no OIDC discovery URL configured")) @@ -365,7 +365,7 @@ var _ = Describe("Repo Config", func() { string(OidcClientId): "client-id", string(OidcUsername): "username", string(OidcPassword): "password"}) - _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc, emptyMockExtractConfigFromConfigMap, false) Expect(err).NotTo(HaveOccurred()) getClientRepoConfig(featureStore, nil) }) From bb6fb52b386481ad8ea26391751b32a038a248b7 Mon Sep 17 00:00:00 2001 From: Aniket Paluskar Date: Tue, 7 Apr 2026 21:18:16 +0530 Subject: [PATCH 39/39] Provision TokenReview RBAC for OIDC auth and add SSL error logging in token parser Signed-off-by: Aniket Paluskar --- .../feast-operator.clusterserviceversion.yaml | 2 +- .../internal/controller/authz/authz.go | 15 ++++++++++++ .../permissions/auth/oidc_token_parser.py | 24 ++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index a1664fde178..11b7ceaf425 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -50,7 +50,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-04-06T22:05:44Z" + createdAt: "2026-04-07T13:49:25Z" operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 name: feast-operator.v0.61.0 diff --git a/infra/feast-operator/internal/controller/authz/authz.go b/infra/feast-operator/internal/controller/authz/authz.go index 9cb5b7c9554..e9811c1c789 100644 --- a/infra/feast-operator/internal/controller/authz/authz.go +++ b/infra/feast-operator/internal/controller/authz/authz.go @@ -25,6 +25,16 @@ func (authz *FeastAuthorization) Deploy() error { _ = authz.Handler.DeleteOwnedFeastObj(authz.initFeastRole()) _ = authz.Handler.DeleteOwnedFeastObj(authz.initFeastRoleBinding()) apimeta.RemoveStatusCondition(&authz.Handler.FeatureStore.Status.Conditions, feastKubernetesAuthConditions[metav1.ConditionTrue].Type) + + if authz.isOidcAuth() { + if err := authz.createFeastClusterRole(); err != nil { + return err + } + if err := authz.createFeastClusterRoleBinding(); err != nil { + return err + } + } + return nil } @@ -33,6 +43,11 @@ func (authz *FeastAuthorization) isKubernetesAuth() bool { return authzConfig != nil && authzConfig.KubernetesAuthz != nil } +func (authz *FeastAuthorization) isOidcAuth() bool { + authzConfig := authz.Handler.FeatureStore.Status.Applied.AuthzConfig + return authzConfig != nil && authzConfig.OidcAuthz != nil +} + func (authz *FeastAuthorization) deployKubernetesAuth() error { if authz.isKubernetesAuth() { authz.removeOrphanedRoles() diff --git a/sdk/python/feast/permissions/auth/oidc_token_parser.py b/sdk/python/feast/permissions/auth/oidc_token_parser.py index 2b67be2e6e2..7940c9d7525 100644 --- a/sdk/python/feast/permissions/auth/oidc_token_parser.py +++ b/sdk/python/feast/permissions/auth/oidc_token_parser.py @@ -93,6 +93,16 @@ def _extract_claim(data: dict, *keys: str, expected_type: type = list): return expected_type() return node + @staticmethod + def _is_ssl_error(exc: BaseException) -> bool: + """Walk the exception chain looking for SSL-related errors.""" + current: Optional[BaseException] = exc + while current is not None: + if isinstance(current, ssl.SSLError): + return True + current = current.__cause__ or current.__context__ + return False + def _decode_token(self, access_token: str) -> dict: """Fetch the JWKS signing key and decode + verify the JWT.""" optional_custom_headers = {"User-agent": "custom-user-agent"} @@ -164,6 +174,12 @@ async def user_details_from_access_token(self, access_token: str) -> User: await self._validate_token(access_token) logger.debug("Token successfully validated.") except Exception as e: + if self._is_ssl_error(e): + logger.error( + "OIDC provider SSL certificate verification failed. " + "If using a self-signed certificate, set verify_ssl: false " + "or provide a CA certificate via ca_cert_path." + ) logger.error(f"Token validation failed: {e}") raise AuthenticationError(f"Invalid token: {e}") @@ -188,7 +204,13 @@ async def user_details_from_access_token(self, access_token: str) -> User: roles=roles, groups=groups, ) - except jwt.exceptions.PyJWTError: + except jwt.exceptions.PyJWTError as e: + if self._is_ssl_error(e): + logger.error( + "OIDC JWKS endpoint SSL certificate verification failed. " + "If using a self-signed certificate, set verify_ssl: false " + "or provide a CA certificate via ca_cert_path." + ) logger.exception("Exception while parsing the token:") raise AuthenticationError("Invalid token.")