diff --git a/Makefile b/Makefile index d049439d504..e1d10404ded 100644 --- a/Makefile +++ b/Makefile @@ -620,6 +620,11 @@ build-feature-server-dev-docker: ## Build Feature Server Dev Docker image -t $(REGISTRY)/feature-server:$(VERSION) \ -f sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev --load . +build-feature-server-dev-docker_on_mac: ## Build Feature Server Dev Docker image on Mac + docker buildx build --platform linux/amd64 \ + -t $(REGISTRY)/feature-server:$(VERSION) \ + -f sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev --load . + push-feature-server-dev-docker: ## Push Feature Server Dev Docker image docker push $(REGISTRY)/feature-server:$(VERSION) diff --git a/docs/changelogs/Groups_Namespaces_Auth_implmentation_summary.md b/docs/changelogs/Groups_Namespaces_Auth_implmentation_summary.md new file mode 100644 index 00000000000..9b4b8b681bb --- /dev/null +++ b/docs/changelogs/Groups_Namespaces_Auth_implmentation_summary.md @@ -0,0 +1,129 @@ +# Groups and Namespaces Based Authorization Implementation Summary + +## Overview +This document summarizes the implementation of groups and namespaces extraction support in Feast for user authentication in Pull Request https://github.com/feast-dev/feast/pull/5619. + +## Changes Made + +### 1. Enhanced User Model (`sdk/python/feast/permissions/user.py`) +- **Extended User class** to include `groups` and `namespaces` attributes +- **Added methods**: + - `has_matching_group()`: Check if user has required groups + - `has_matching_namespace()`: Check if user has required namespaces +- **Maintained backward compatibility** with existing role-based functionality + +### 2. New Policy Types (`sdk/python/feast/permissions/policy.py`) +- **GroupBasedPolicy**: Grants access based on user group membership +- **NamespaceBasedPolicy**: Grants access based on user namespace association +- **CombinedGroupNamespacePolicy**: Requires both group OR namespace match +- **Updated Policy.from_proto()** to handle new policy types +- **Maintained backward compatibility** with existing RoleBasedPolicy + +### 3. Protobuf Definitions (`protos/feast/core/Policy.proto`) +- **Added GroupBasedPolicy message** with groups field +- **Added NamespaceBasedPolicy message** with namespaces field +- **Extended Policy message** to include new policy types in oneof +- **[Love] Regenerated Python protobuf files** using `make compile-protos-python` + +### 4. Token Access Review Integration (`sdk/python/feast/permissions/auth/kubernetes_token_parser.py`) +- **Added AuthenticationV1Api client** for Token Access Review +- **Implemented `_extract_groups_and_namespaces_from_token()`**: + - Uses Kubernetes Token Access Review API + - Extracts groups and namespaces from token response + - Handles both service accounts and regular users +- **Updated `user_details_from_access_token()`** to include groups and namespaces + +### 5. Client SDK Updates (`sdk/python/feast/permissions/client/kubernetes_auth_client_manager.py`) +- **Extended KubernetesAuthConfig** to support user tokens +- **Updated `get_token()` method** to check for user_token in config +- **Maintained backward compatibility** with service account tokens + +### 6. Configuration Model (`sdk/python/feast/permissions/auth_model.py`) +- **Added user_token field** to KubernetesAuthConfig for external users +- **Maintained backward compatibility** with existing configurations + +### 7. Comprehensive Tests (`sdk/python/tests/permissions/test_groups_namespaces_auth.py`) +- **15 test cases** covering all new functionality +- **Tests for**: + - User creation with groups/namespaces + - Group matching functionality + - Namespace matching functionality + - All new policy types + - Backward compatibility + +### 8. Documentation (`docs/getting-started/components/groups_namespaces_auth.md`) +- **Usage examples** and configuration guides +- **Security considerations** and best practices +- **Troubleshooting guide** and migration instructions + + +## Key Features Implemented + +### ✅ Token Access Review Integration +- Uses Kubernetes Token Access Review API to extract user details +- Handles both service accounts and external users + +### ✅ Groups and Namespaces Extraction +- Extracts groups and namespaces from token response +- Supports both service account and regular user tokens + +### ✅ New Policy Types +- **GroupBasedPolicy**: Access based on group membership +- **NamespaceBasedPolicy**: Access based on namespace association +- **CombinedGroupNamespacePolicy**: Requires either group OR namespace + +### ✅ Client SDK Support +- Extended to support user tokens for external users +- Maintains backward compatibility with service account tokens +- New parameter in KubernetesAuthConfig for user tokens + + +## Usage Examples + +### Basic Group-Based Permission +```python +from feast.permissions.policy import GroupBasedPolicy +from feast.permissions.permission import Permission + +policy = GroupBasedPolicy(groups=["data-team", "ml-engineers"]) +permission = Permission( + name="data_team_access", + types=ALL_RESOURCE_TYPES, + policy=policy, + actions=[AuthzedAction.DESCRIBE] + READ +) +``` + +### Basic Namespace-Based Permission +```python +from feast.permissions.policy import NamespaceBasedPolicy +from feast.permissions.permission import Permission + +policy = NamespaceBasedPolicy(namespaces=["de-dsp", "ml-dsp"]) +permission = Permission( + name="data_team_access", + types=ALL_RESOURCE_TYPES, + policy=policy, + actions=[AuthzedAction.DESCRIBE] + READ +) +``` + +### Combined Group + Namespace Permission +```python +from feast.permissions.policy import CombinedGroupNamespacePolicy + +policy = CombinedGroupNamespacePolicy( + groups=["data-team"], + namespaces=["production"] +) +``` + +### Client Configuration with User Token +```python +from feast.permissions.auth_model import KubernetesAuthConfig + +auth_config = KubernetesAuthConfig( + type="kubernetes", + user_token="your-kubernetes-user-token" # For external users +) +``` diff --git a/docs/getting-started/architecture/overview.md b/docs/getting-started/architecture/overview.md index 86ee75aaa63..e5420e77fab 100644 --- a/docs/getting-started/architecture/overview.md +++ b/docs/getting-started/architecture/overview.md @@ -18,6 +18,6 @@ typically your Offline Store). We are exploring adding a default streaming engin * We recommend [using Python](language.md) for your Feature Store microservice. As mentioned in the document, precomputing features is the recommended optimal path to ensure low latency performance. Reducing feature serving to a lightweight database lookup is the ideal pattern, which means the marginal overhead of Python should be tolerable. Because of this we believe the pros of Python outweigh the costs, as reimplementing feature logic is undesirable. Java and Go Clients are also available for online feature retrieval. -* [Role-Based Access Control (RBAC)](rbac.md) is a security mechanism that restricts access to resources based on the roles of individual users within an organization. In the context of the Feast, RBAC ensures that only authorized users or groups can access or modify specific resources, thereby maintaining data security and operational integrity. +* [Role-Based Access Control (RBAC)](rbac.md) is a security mechanism that restricts access to resources based on the roles/groups/namespaces of individual users within an organization. In the context of the Feast, RBAC ensures that only authorized users or groups can access or modify specific resources, thereby maintaining data security and operational integrity. diff --git a/docs/getting-started/architecture/rbac.md b/docs/getting-started/architecture/rbac.md index 9a51fba6ac7..edbcd595dbd 100644 --- a/docs/getting-started/architecture/rbac.md +++ b/docs/getting-started/architecture/rbac.md @@ -2,13 +2,13 @@ ## Introduction -Role-Based Access Control (RBAC) is a security mechanism that restricts access to resources based on the roles of individual users within an organization. In the context of the Feast, RBAC ensures that only authorized users or groups can access or modify specific resources, thereby maintaining data security and operational integrity. +Role-Based Access Control (RBAC) is a security mechanism that restricts access to resources based on the roles/groups/namespaces of individual users within an organization. In the context of the Feast, RBAC ensures that only authorized users or groups/namespaces can access or modify specific resources, thereby maintaining data security and operational integrity. ## Functional Requirements The RBAC implementation in Feast is designed to: -- **Assign Permissions**: Allow administrators to assign permissions for various operations and resources to users or groups based on their roles. +- **Assign Permissions**: Allow administrators to assign permissions for various operations and resources to users or groups/namespaces. - **Seamless Integration**: Integrate smoothly with existing business code without requiring significant modifications. - **Backward Compatibility**: Maintain support for non-authorized models as the default to ensure backward compatibility. @@ -35,7 +35,7 @@ The RBAC system in Feast uses a permission model that defines the following conc - **Resource**: An object within Feast that needs to be secured against unauthorized access. - **Action**: A logical operation performed on a resource, such as Create, Describe, Update, Delete, Read, or write operations. -- **Policy**: A set of rules that enforce authorization decisions on resources. The default implementation uses role-based policies. +- **Policy**: A set of rules that enforce authorization decisions on resources. The polices are based on user roles or groups or namespaces or combined. diff --git a/docs/getting-started/components/authz_manager.md b/docs/getting-started/components/authz_manager.md index 4014c697efb..e5aa0661619 100644 --- a/docs/getting-started/components/authz_manager.md +++ b/docs/getting-started/components/authz_manager.md @@ -99,7 +99,7 @@ auth: ### 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. +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. An example of Kubernetes RBAC authorization configuration is the following: {% hint style="info" %} @@ -109,19 +109,12 @@ An example of Kubernetes RBAC authorization configuration is the following: project: my-project auth: type: kubernetes + user_token: #Optional, else service account token Or env var is used for getting the token ... ``` In case the client cannot run on the same cluster as the servers, the client token can be injected using the `LOCAL_K8S_TOKEN` environment variable on the client side. The value must refer to the token of a service account created on the servers cluster -and linked to the desired RBAC roles. +and linked to the desired RBAC roles/groups/namespaces. -#### Setting Up Kubernetes RBAC for Feast - -To ensure the Kubernetes RBAC environment aligns with Feast's RBAC configuration, follow these guidelines: -* The roles defined in Feast `Permission` instances must have corresponding Kubernetes RBAC `Role` names. -* The Kubernetes RBAC `Role` must reside in the same namespace as the Feast service. -* The client application can run in a different namespace, using its own dedicated `ServiceAccount`. -* Finally, the `RoleBinding` that links the client `ServiceAccount` to the RBAC `Role` must be defined in the namespace of the Feast service. - -If the above rules are satisfied, the Feast service must be granted permissions to fetch `RoleBinding` instances from the local namespace. \ No newline at end of file +More details can be found in [Setting up kubernetes doc](../../reference/auth/kubernetes_auth_setup.md) diff --git a/docs/getting-started/concepts/permission.md b/docs/getting-started/concepts/permission.md index 8db67032878..77950106669 100644 --- a/docs/getting-started/concepts/permission.md +++ b/docs/getting-started/concepts/permission.md @@ -101,6 +101,15 @@ Permission( ) ``` +## Permission granting order + +When mixing and matching policies in permissions script, the permission granting order is as follows: + +1. The first matching policy wins in the list of policies and the permission is granted based on the matching policy rules and rest policies are ignored. +2. If any policy matches from the list of policies, the permission is granted based on the matching policy rules and rest policies are ignored +3. If no policy matches, the permission is denied + + ## Authorization configuration In order to leverage the permission functionality, the `auth` section is needed in the `feature_store.yaml` configuration. Currently, Feast supports OIDC and Kubernetes RBAC authorization protocols. diff --git a/docs/reference/auth/kubernetes_auth_setup.md b/docs/reference/auth/kubernetes_auth_setup.md new file mode 100644 index 00000000000..447e1d5a684 --- /dev/null +++ b/docs/reference/auth/kubernetes_auth_setup.md @@ -0,0 +1,182 @@ +# Setting up the kubernetes Auth + +This document describes the authentication and authorization capabilities in Feast that support groups, namespaces and roles extraction from Kubernetes tokens. + +## Overview + +Feast supports extracting user groups, namespaces and roles of both Service Account and User from Kubernetes authentication tokens. This allows for more granular access control based on: + +- **Groups**: User groups associated directly with User/SA and from associated namespace +- **Namespaces**: Kubernetes namespaces associated with User/SA +- **Roles**: Kubernetes roles associated with User/SA + +## Key Features + +### Setting Up Kubernetes RBAC for Feast + +#### Role based auth setup + +To ensure the Kubernetes RBAC environment aligns with Feast's RBAC configuration, follow these guidelines: +* The roles defined in Feast `Permission` instances must have corresponding Kubernetes RBAC `Role` names. +* The Kubernetes RBAC `Role` must reside in the same namespace as the Feast service. +* The client application can run in a different namespace, using its own dedicated `ServiceAccount`. +* Finally, the `RoleBinding` that links the client `ServiceAccount` to the RBAC `Role` must be defined in the namespace of the Feast service. + +#### Group and Namespace based auth setup + +To ensure the Kubernetes RBAC environment aligns with Feast's RBAC configuration, follow these guidelines: +* The groups and namespaces defined in Feast `Permission` instances must have corresponding Kubernetes `Group` and `Namespace` names. +* The user or service account must reside in the group or namespace defined in the Feast `Permission` instances. +* The client application can run in a different namespace, using its own dedicated `ServiceAccount` or user. +* Finally, the feast service grants access based on the group and namespace association defined in the Feast `Permission` instances. + +## Policy Types + +### RoleBasedPolicy +Grants access based on user role membership. + +```python +from feast.permissions.policy import RoleBasedPolicy + +policy = RoleBasedPolicy(roles=["data-team", "ml-engineers"]) +``` + +### GroupBasedPolicy +Grants access based on user group membership. + +```python +from feast.permissions.policy import GroupBasedPolicy + +policy = GroupBasedPolicy(groups=["data-team", "ml-engineers"]) +``` + +#### NamespaceBasedPolicy +Grants access based on user namespace association. + +```python +from feast.permissions.policy import NamespaceBasedPolicy + +policy = NamespaceBasedPolicy(namespaces=["production", "staging"]) +``` + +#### CombinedGroupNamespacePolicy +Grants access only when user is added into either permitted groups OR namespaces. + +```python +from feast.permissions.policy import CombinedGroupNamespacePolicy + +policy = CombinedGroupNamespacePolicy( + groups=["data-team"], + namespaces=["production"] +) +``` + +## Configuration + +### Server Configuration + +The server automatically extracts groups, namespaces and roles when using Kubernetes authentication. No additional configuration is required beyond the existing Kubernetes auth setup. + +### Client Configuration + +For external users (not service accounts), you can provide a user token in the configuration: + +Refer examples of providing the token are described in doc [User Token Provisioning](./user_token_provisioning.md) + +## Usage Examples + +### Basic Permission Setup + +```python +from feast.feast_object import ALL_RESOURCE_TYPES +from feast.permissions.action import READ, AuthzedAction, ALL_ACTIONS +from feast.permissions.permission import Permission +from feast.permissions.policy import ( + RoleBasedPolicy, + GroupBasedPolicy, + NamespaceBasedPolicy, + CombinedGroupNamespacePolicy +) + +# Role-based permission +role_perm = Permission( + name="role_permission", + types=ALL_RESOURCE_TYPES, + policy=RoleBasedPolicy(roles=["reader-role"]), + actions=[AuthzedAction.DESCRIBE] + READ +) + +# Group-based permission (new) +data_team_perm = Permission( + name="data_team_permission", + types=ALL_RESOURCE_TYPES, + policy=GroupBasedPolicy(groups=["data-team", "ml-engineers"]), + actions=[AuthzedAction.DESCRIBE] + READ +) + +# Namespace-based permission (new) +prod_perm = Permission( + name="production_permission", + types=ALL_RESOURCE_TYPES, + policy=NamespaceBasedPolicy(namespaces=["production"]), + actions=[AuthzedAction.DESCRIBE] + READ +) + +# Combined permission (new) +dev_staging_perm = Permission( + name="dev_staging_permission", + types=ALL_RESOURCE_TYPES, + policy=CombinedGroupNamespacePolicy( + groups=["dev-team"], + namespaces=["staging"] + ), + actions=ALL_ACTIONS +) +``` + +### Applying Permissions + +Run `feast apply` from CLI/API/SDK on server or from client(if permitted) to apply the permissions. + +## Troubleshooting + +### Common Issues + +1. **Token Access Review Fails** + - Check that the Feast server has the required RBAC permissions + - Verify the token is valid and not expired + - Check server logs for detailed error messages in debug mode + +2. **Groups/Namespaces Not Extracted** + - Verify the token contains the expected claims + - Check that the user is properly configured in Kubernetes/ODH/RHOAI + +3. **Permission Denied** + - Verify the user is added to required groups/namespaces Or has the required role assigned + - Check that the policy is correctly configured + - Review the permission evaluation logs + +## Migration Guide + +### From Role-Based to Group/Namespace-Based + +1. **Identify User Groups**: Determine which groups your users belong to +2. **Map Namespaces**: Identify which namespaces users should have access to +3. **Create New Policies**: Define group-based and namespace-based policies +4. **Test Gradually**: Start with read-only permissions and gradually expand +5. **Monitor**: Watch logs to ensure proper authentication and authorization + + +## Best Practices + +1. **Principle of Least Privilege**: Grant only the minimum required permissions +2. **Group Organization**: Organize users into logical groups based on their responsibilities +3. **Namespace Isolation**: Use namespaces to isolate different environments or teams +4. **Regular Audits**: Periodically review and audit permissions + +## Related Documentation + +- [Authorization Manager](./authz_manager.md) +- [Permission Model](../concepts/permission.md) +- [RBAC Architecture](../architecture/rbac.md) +- [Kubernetes RBAC Authorization](./authz_manager.md#kubernetes-rbac-authorization) diff --git a/docs/reference/auth/user_token_provisioning.md b/docs/reference/auth/user_token_provisioning.md new file mode 100644 index 00000000000..05c772891b3 --- /dev/null +++ b/docs/reference/auth/user_token_provisioning.md @@ -0,0 +1,289 @@ +# User Token Provisioning Guide + +This document explains how users can provide Kubernetes user tokens from CLI, API, and SDK from the client side for Feast authentication. + +## Overview + +Feast supports multiple methods for providing user tokens to authenticate with Kubernetes-based feature stores. This guide covers all the available approaches for different use cases and environments. + +## Table of Contents + +- [SDK Configuration (Python)](#sdk-configuration-python) +- [CLI Usage](#cli-usage) +- [API Usage (REST/gRPC)](#api-usage-restgrpc) +- [Programmatic SDK Usage](#programmatic-sdk-usage) +- [Configuration Priority](#configuration-priority) +- [Security Best Practices](#security-best-practices) +- [Complete Examples](#complete-examples) +- [Troubleshooting](#troubleshooting) + +## SDK Configuration (Python) + +### Method 1: Direct Configuration in FeatureStore + +```python +from feast import FeatureStore +from feast.permissions.auth_model import KubernetesAuthConfig + +# Create auth config with user token +auth_config = KubernetesAuthConfig( + type="kubernetes", + user_token="your-kubernetes-user-token-here" +) + +# Initialize FeatureStore with auth config +fs = FeatureStore( + repo_path="path/to/feature_repo", + auth_config=auth_config +) +``` + +### Method 2: Environment Variable + +```python +import os +from feast import FeatureStore + +# Set environment variable +os.environ["LOCAL_K8S_TOKEN"] = "your-kubernetes-user-token-here" + +# FeatureStore will automatically use the token +fs = FeatureStore("path/to/feature_repo") +``` + +### Method 3: Configuration File + +Create or update your `feature_store.yaml`: + +```yaml +project: my-project +auth: + type: kubernetes + user_token: "your-kubernetes-user-token-here" +``` +Feature Store will read the token from config YAML +```python +fs = FeatureStore("path/to/feature_repo") +``` + +**Note**: The `KubernetesAuthConfig` class is configured to allow extra fields, so the `user_token` field will be properly recognized when loaded from YAML files. + +Then use in Python: + +```python +from feast import FeatureStore + +# FeatureStore will read auth config from feature_store.yaml +fs = FeatureStore("path/to/feature_repo") +``` + +## CLI Usage + +### Method 1: Environment Variable + +```bash +# Set the token as environment variable +export LOCAL_K8S_TOKEN="your-kubernetes-user-token-here" + +# Use Feast CLI commands +feast apply +feast materialize +feast get-online-features \ + --features feature1,feature2 \ + --entity-rows '{"entity_id": "123"}' +``` + +### Method 2: Configuration File + +Create or update your `feature_store.yaml`: + +```yaml +project: my-project +auth: + type: kubernetes + user_token: "your-kubernetes-user-token-here" +``` + +Then use CLI commands: + +```bash +feast apply +feast materialize +feast get-online-features \ + --features feature1,feature2 \ + --entity-rows '{"entity_id": "123"}' +``` + +## API Usage (REST/gRPC) + +### REST API + +#### Method 1: Authorization Header + +```python +import requests + +# For REST API +headers = { + "Authorization": "Bearer your-kubernetes-user-token-here", + "Content-Type": "application/json" +} + +# Get features +response = requests.get( + "http://feast-server/features", + headers=headers +) + +# Get online features +response = requests.post( + "http://feast-server/get-online-features", + headers=headers, + json={ + "features": ["feature1", "feature2"], + "entity_rows": [{"entity_id": "123"}] + } +) +``` + +#### Method 2: Using requests Session + +```python +import requests +from requests.auth import HTTPBearerAuth + +# Create session with auth +session = requests.Session() +session.auth = HTTPBearerAuth("your-kubernetes-user-token-here") + +# Make requests +response = session.get("http://feast-server/features") +``` + +### gRPC API + +#### Method 1: gRPC Metadata + +```python +import grpc +from feast.protos.feast.serving.ServingService_pb2_grpc import ServingServiceStub +from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesRequest + +# Create gRPC channel +channel = grpc.insecure_channel('feast-server:6565') +stub = ServingServiceStub(channel) + +# Create metadata with auth token +metadata = [('authorization', 'Bearer your-kubernetes-user-token-here')] + +# Create request +request = GetOnlineFeaturesRequest( + features=["feature1", "feature2"], + entity_rows=[{"entity_id": "123"}] +) + +# Make gRPC call +response = stub.GetOnlineFeatures(request, metadata=metadata) +``` + +#### Method 2: gRPC Interceptor + +```python +import grpc +from feast.protos.feast.serving.ServingService_pb2_grpc import ServingServiceStub + +class AuthInterceptor(grpc.UnaryUnaryClientInterceptor): + def __init__(self, token): + self.token = token + + def intercept_unary_unary(self, continuation, client_call_details, request): + # Add auth metadata + metadata = list(client_call_details.metadata or []) + metadata.append(('authorization', f'Bearer {self.token}')) + + # Update call details + client_call_details = client_call_details._replace(metadata=metadata) + return continuation(client_call_details, request) + +# Create channel with interceptor +channel = grpc.insecure_channel('feast-server:6565') +interceptor = AuthInterceptor("your-kubernetes-user-token-here") +channel = grpc.intercept_channel(channel, interceptor) + +# Use the channel +stub = ServingServiceStub(channel) +``` + +## Programmatic SDK Usage + +### Token from Kubernetes Config + +```python +from feast import FeatureStore +from feast.permissions.auth_model import KubernetesAuthConfig +from kubernetes import client, config + +# Load kubeconfig and get token +config.load_kube_config() +v1 = client.CoreV1Api() + +# Get token from Kubernetes API or secure storage +def get_token_from_k8s(): + # Example: Get token from secret + secret = v1.read_namespaced_secret( + name="user-token", + namespace="default" + ) + return secret.data["token"].decode("utf-8") + +user_token = get_token_from_k8s() + +auth_config = KubernetesAuthConfig( + type="kubernetes", + user_token=user_token +) + +fs = FeatureStore( + repo_path="path/to/feature_repo", + auth_config=auth_config +) +``` + +## Configuration Priority + +The system checks for tokens in this order: + +1. **Intra-communication**: `INTRA_COMMUNICATION_BASE64` (for service-to-service) +2. **Direct configuration**: `user_token` in `KubernetesAuthConfig` or in `feature_store.yaml` +3. **Service account token**: `/var/run/secrets/kubernetes.io/serviceaccount/token` (for pods) +4. **Environment variable**: `LOCAL_K8S_TOKEN` + + +## Troubleshooting + +### Common Issues + +1. **Token Not Found** + ``` + Error: Missing authentication token + ``` + **Solution**: Ensure the token is provided through one of the supported methods. + +2. **Invalid Token** + ``` + Error: Invalid or expired access token + ``` + **Solution**: Verify the token is valid and not expired. + +3. **Permission Denied** + ``` + Error: User is not added into the permitted groups / Namespaces + ``` + **Solution**: Check that the user has the required groups/namespaces access. + +## Related Documentation + +- [Groups and Namespaces Authentication](./groups_namespaces_auth.md) +- [Authorization Manager](../../getting-started/components/authz_manager.md) +- [Permission Model](../../getting-started/concepts/permission.md) +- [RBAC Architecture](../../getting-started/architecture/rbac.md) diff --git a/examples/operator-rbac-openshift-tls/2-client-rbac-test-pod.ipynb b/examples/operator-rbac-openshift-tls/2-client-rbac-test-pod.ipynb index e628f0f3bd0..a7f9ec7fbe6 100644 --- a/examples/operator-rbac-openshift-tls/2-client-rbac-test-pod.ipynb +++ b/examples/operator-rbac-openshift-tls/2-client-rbac-test-pod.ipynb @@ -79,19 +79,25 @@ "id": "8e00a2f3", "metadata": {}, "source": [ - "### Change the Cert path for all servers to CACert in repo config\n", + "### Certificate Path Configuration\n", "\n", - "Note: Below example is for MacOS, For linux remove empty `''`." + "The Feast operator automatically configures the correct certificate path based on the deployment environment:\n", + "- For RHOAI/ODH deployments with custom CA bundle: Uses `/etc/pki/tls/custom-certs/service-ca.crt`\n", + "- For standard deployments: Uses individual service certificate paths like `/tls/offline/tls.crt`\n", + "\n", + "No manual configuration is needed - the operator handles this automatically." ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "fcb92e6a", "metadata": {}, "outputs": [], "source": [ - "!sed -i '' 's|cert: /tls/[^/]*/tls.crt|cert: /etc/pki/tls/custom-certs/service-ca.crt|g' client/feature_repo/feature_store.yaml" + "# The operator now automatically configures the correct certificate path\n", + "# No manual modification needed - the feature_store.yaml already has the correct paths\n", + "print(\"Certificate paths are automatically configured by the Feast operator\")" ] }, { @@ -707,7 +713,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.11.13" } }, "nbformat": 4, diff --git a/examples/operator-rbac-openshift-tls/permissions_with_groups_namespaces.py b/examples/operator-rbac-openshift-tls/permissions_with_groups_namespaces.py new file mode 100644 index 00000000000..5565c612902 --- /dev/null +++ b/examples/operator-rbac-openshift-tls/permissions_with_groups_namespaces.py @@ -0,0 +1,117 @@ +# Example permissions configuration with groups and namespaces support +# This demonstrates how to use the new group-based and namespace-based policies +# in addition to the existing role-based policies + +from feast.feast_object import ALL_FEATURE_VIEW_TYPES, ALL_RESOURCE_TYPES +from feast.project import Project +from feast.entity import Entity +from feast.feature_view import FeatureView +from feast.on_demand_feature_view import OnDemandFeatureView +from feast.batch_feature_view import BatchFeatureView +from feast.stream_feature_view import StreamFeatureView +from feast.feature_service import FeatureService +from feast.data_source import DataSource +from feast.saved_dataset import SavedDataset +from feast.permissions.permission import Permission +from feast.permissions.action import READ, AuthzedAction, ALL_ACTIONS +from feast.permissions.policy import RoleBasedPolicy, GroupBasedPolicy, NamespaceBasedPolicy, CombinedGroupNamespacePolicy + +# New Testing + +WITHOUT_DATA_SOURCE = [Project, Entity, FeatureService, SavedDataset] + ALL_FEATURE_VIEW_TYPES + +ONLY_ENTITIES = [Entity] + +ONLY_DS = [DataSource] + +# Define K8s roles (existing functionality) +admin_roles = ["feast-writer"] # Full access (can create, update, delete) Feast Resources +user_roles = ["feast-reader"] # Read-only access on Feast Resources + +# Define groups for different teams +data_team_groups = ["data-team", "ml-engineers"] +dev_team_groups = ["dev-team", "developers"] +admin_groups = ["feast-admins", "platform-admins"] + +# Define namespaces for different environments +prod_namespaces = ["feast"] + +# pre_changed = Permission(name="entity_reader", types=ONLY_ENTITIES, policy=NamespaceBasedPolicy(namespaces=prod_namespaces), actions=[AuthzedAction.DESCRIBE] + READ) +only_entities = Permission( + name="pre_Changed", + types=ONLY_ENTITIES, + policy=NamespaceBasedPolicy(namespaces=prod_namespaces), + actions=[AuthzedAction.DESCRIBE] + READ +) +only_ds = Permission(name="entity_reader", types=ONLY_DS, policy=NamespaceBasedPolicy(namespaces=[prod_namespaces]), actions=[AuthzedAction.DESCRIBE] + READ) +staging_namespaces = ["staging", "dev"] +test_namespaces = ["test", "testing"] + +# Role-based permissions (existing functionality) +# - Grants read and describing Feast objects access +user_perm = Permission( + name="feast_user_permission", + types=ALL_RESOURCE_TYPES, + policy=RoleBasedPolicy(roles=user_roles), + actions=[AuthzedAction.DESCRIBE] + READ # Read access (READ_ONLINE, READ_OFFLINE) + describe other Feast Resources. +) + +# Admin permissions (existing functionality) +# - Grants full control over all resources +admin_perm = Permission( + name="feast_admin_permission", + types=ALL_RESOURCE_TYPES, + policy=RoleBasedPolicy(roles=admin_roles), + actions=ALL_ACTIONS # Full permissions: CREATE, UPDATE, DELETE, READ, WRITE +) + +# Group-based permissions (new functionality) +# - Grants read access to data team members +data_team_perm = Permission( + name="data_team_read_permission", + types=ALL_RESOURCE_TYPES, + policy=GroupBasedPolicy(groups=data_team_groups), + actions=[AuthzedAction.DESCRIBE] + READ +) + +# - Grants full access to admin groups +admin_group_perm = Permission( + name="admin_group_permission", + types=ALL_RESOURCE_TYPES, + policy=GroupBasedPolicy(groups=admin_groups), + actions=ALL_ACTIONS +) + +# Namespace-based permissions (new functionality) +# - Grants read access to production namespace users +prod_read_perm = Permission( + name="production_read_permission", + types=ALL_RESOURCE_TYPES, + policy=NamespaceBasedPolicy(namespaces=prod_namespaces), + actions=[AuthzedAction.DESCRIBE] + READ +) + +# # - Grants full access to staging namespace users +staging_full_perm = Permission( + name="staging_full_permission", + types=ALL_RESOURCE_TYPES, + policy=NamespaceBasedPolicy(namespaces=staging_namespaces), + actions=ALL_ACTIONS +) + +# # Combined permissions (using combined policy type) +# # - Grants read access to dev team members in test namespaces +dev_test_perm = Permission( + name="dev_test_permission", + types=ALL_RESOURCE_TYPES, + policy=CombinedGroupNamespacePolicy(groups=dev_team_groups, namespaces=test_namespaces), + actions=[AuthzedAction.DESCRIBE] + READ +) + +# # - Grants full access to data team members in staging namespaces +data_staging_perm = Permission( + name="data_staging_permission", + types=ALL_RESOURCE_TYPES, + policy=CombinedGroupNamespacePolicy(groups=data_team_groups, namespaces=staging_namespaces), + actions=ALL_ACTIONS +) diff --git a/infra/feast-operator/Makefile b/infra/feast-operator/Makefile index ec8a68431d9..a220aa014b5 100644 --- a/infra/feast-operator/Makefile +++ b/infra/feast-operator/Makefile @@ -50,6 +50,8 @@ endif # This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. OPERATOR_SDK_VERSION ?= v1.38.0 # Image URL to use all building/pushing image targets +# During development and testing, and before make deploy we need to export FS_IMG to point to +# the dev image generated using command `make build-feature-server-dev-docker` IMG ?= $(IMAGE_TAG_BASE):$(VERSION) FS_IMG ?= quay.io/feastdev/feature-server:$(VERSION) CJ_IMG ?= quay.io/openshift/origin-cli:4.17 @@ -160,6 +162,10 @@ run: manifests generate fmt vet ## Run a controller from your host. docker-build: ## Build docker image with the manager. $(CONTAINER_TOOL) build -t ${IMG} --load . +.PHONY: docker-build-on-mac +docker-build-on-mac: ## Build docker image with the manager on Mac. + $(CONTAINER_TOOL) build --platform linux/amd64 -t ${IMG} --load . + ## Build feast docker image. .PHONY: feast-ci-dev-docker-img feast-ci-dev-docker-img: diff --git a/infra/feast-operator/config/rbac/role.yaml b/infra/feast-operator/config/rbac/role.yaml index 207f19ce4a4..56a952dd95c 100644 --- a/infra/feast-operator/config/rbac/role.yaml +++ b/infra/feast-operator/config/rbac/role.yaml @@ -15,6 +15,12 @@ rules: - list - update - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create - apiGroups: - batch resources: @@ -44,11 +50,13 @@ rules: - apiGroups: - "" resources: + - namespaces - pods - secrets verbs: - get - list + - watch - apiGroups: - "" resources: @@ -84,8 +92,11 @@ rules: - apiGroups: - rbac.authorization.k8s.io resources: + - clusterrolebindings + - clusterroles - rolebindings - roles + - subjectaccessreviews verbs: - create - delete diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index dce055f7378..f96ffae9142 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -8211,6 +8211,12 @@ rules: - list - update - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create - apiGroups: - batch resources: @@ -8240,11 +8246,13 @@ rules: - apiGroups: - "" resources: + - namespaces - pods - secrets verbs: - get - list + - watch - apiGroups: - "" resources: @@ -8280,8 +8288,11 @@ rules: - apiGroups: - rbac.authorization.k8s.io resources: + - clusterrolebindings + - clusterroles - rolebindings - roles + - subjectaccessreviews verbs: - create - delete diff --git a/infra/feast-operator/internal/controller/authz/authz.go b/infra/feast-operator/internal/controller/authz/authz.go index b0f8aaafe49..33a3594607f 100644 --- a/infra/feast-operator/internal/controller/authz/authz.go +++ b/infra/feast-operator/internal/controller/authz/authz.go @@ -37,6 +37,7 @@ func (authz *FeastAuthorization) deployKubernetesAuth() error { if authz.isKubernetesAuth() { authz.removeOrphanedRoles() + // Create namespace-scoped RBAC resources if err := authz.createFeastRole(); err != nil { return authz.setFeastKubernetesAuthCondition(err) } @@ -44,6 +45,15 @@ func (authz *FeastAuthorization) deployKubernetesAuth() error { return authz.setFeastKubernetesAuthCondition(err) } + // Create cluster-scoped RBAC resources (separate from namespace resources) + if err := authz.createFeastClusterRole(); err != nil { + return authz.setFeastKubernetesAuthCondition(err) + } + if err := authz.createFeastClusterRoleBinding(); err != nil { + return authz.setFeastKubernetesAuthCondition(err) + } + + // Create custom auth roles for _, roleName := range authz.Handler.FeatureStore.Status.Applied.AuthzConfig.KubernetesAuthz.Roles { if err := authz.createAuthRole(roleName); err != nil { return authz.setFeastKubernetesAuthCondition(err) @@ -89,6 +99,106 @@ func (authz *FeastAuthorization) createFeastRole() error { return nil } +func (authz *FeastAuthorization) createFeastClusterRole() error { + logger := log.FromContext(authz.Handler.Context) + clusterRole := authz.initFeastClusterRole() + if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, clusterRole, controllerutil.MutateFn(func() error { + return authz.setFeastClusterRole(clusterRole) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "ClusterRole", clusterRole.Name, "operation", op) + } + + return nil +} + +func (authz *FeastAuthorization) initFeastClusterRole() *rbacv1.ClusterRole { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastClusterRoleName()}, + } + clusterRole.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("ClusterRole")) + return clusterRole +} + +func (authz *FeastAuthorization) setFeastClusterRole(clusterRole *rbacv1.ClusterRole) error { + clusterRole.Labels = authz.getLabels() + clusterRole.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"rolebindings"}, + Verbs: []string{"list"}, + }, + { + APIGroups: []string{"authentication.k8s.io"}, + Resources: []string{"tokenreviews"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"subjectaccessreviews"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"clusterroles"}, + Verbs: []string{"get", "list"}, + }, + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"clusterrolebindings"}, + Verbs: []string{"get", "list"}, + }, + } + // Don't set controller reference for shared ClusterRole + return nil +} + +func (authz *FeastAuthorization) initFeastClusterRoleBinding() *rbacv1.ClusterRoleBinding { + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastClusterRoleBindingName()}, + } + clusterRoleBinding.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("ClusterRoleBinding")) + return clusterRoleBinding +} + +func (authz *FeastAuthorization) setFeastClusterRoleBinding(clusterRoleBinding *rbacv1.ClusterRoleBinding) error { + clusterRoleBinding.Labels = authz.getLabels() + clusterRoleBinding.Subjects = []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: authz.getFeastServiceAccountName(), + Namespace: authz.Handler.FeatureStore.Namespace, + }, + } + clusterRoleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: authz.getFeastClusterRoleName(), + } + return nil +} + +// Create ClusterRoleBinding +func (authz *FeastAuthorization) createFeastClusterRoleBinding() error { + logger := log.FromContext(authz.Handler.Context) + clusterRoleBinding := authz.initFeastClusterRoleBinding() + if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, clusterRoleBinding, controllerutil.MutateFn(func() error { + return authz.setFeastClusterRoleBinding(clusterRoleBinding) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "ClusterRoleBinding", clusterRoleBinding.Name, "operation", op) + } + + return nil +} + func (authz *FeastAuthorization) initFeastRole() *rbacv1.Role { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastRoleName(), Namespace: authz.Handler.FeatureStore.Namespace}, @@ -105,6 +215,31 @@ func (authz *FeastAuthorization) setFeastRole(role *rbacv1.Role) error { Resources: []string{"roles", "rolebindings"}, Verbs: []string{"get", "list", "watch"}, }, + { + APIGroups: []string{"authentication.k8s.io"}, + Resources: []string{"tokenreviews"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"subjectaccessreviews"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"clusterroles"}, + Verbs: []string{"get", "list"}, + }, + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"clusterrolebindings"}, + Verbs: []string{"get", "list"}, + }, } return controllerutil.SetControllerReference(authz.Handler.FeatureStore, role, authz.Handler.Scheme) @@ -205,3 +340,25 @@ func (authz *FeastAuthorization) getFeastRoleName() string { func GetFeastRoleName(featureStore *feastdevv1alpha1.FeatureStore) string { return services.GetFeastName(featureStore) } + +func (authz *FeastAuthorization) getFeastClusterRoleName() string { + return GetFeastClusterRoleName(authz.Handler.FeatureStore) +} + +func GetFeastClusterRoleName(featureStore *feastdevv1alpha1.FeatureStore) string { + // Use a shared ClusterRole name for all Feast instances + // This allows multiple FeatureStores to share the same Token Access Review permissions + return "feast-token-review-cluster-role" +} + +func (authz *FeastAuthorization) getFeastClusterRoleBindingName() string { + return GetFeastClusterRoleBindingName(authz.Handler.FeatureStore) +} + +func GetFeastClusterRoleBindingName(featureStore *feastdevv1alpha1.FeatureStore) string { + return services.GetFeastName(featureStore) + "-cluster-binding" +} + +func (authz *FeastAuthorization) getFeastServiceAccountName() string { + return services.GetFeastName(authz.Handler.FeatureStore) +} diff --git a/infra/feast-operator/internal/controller/featurestore_controller.go b/infra/feast-operator/internal/controller/featurestore_controller.go index e513560c464..74d8c0af9ab 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller.go +++ b/infra/feast-operator/internal/controller/featurestore_controller.go @@ -59,9 +59,10 @@ type FeatureStoreReconciler struct { // +kubebuilder:rbac:groups=feast.dev,resources=featurestores/finalizers,verbs=update // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;create;update;watch;delete // +kubebuilder:rbac:groups=core,resources=services;configmaps;persistentvolumeclaims;serviceaccounts,verbs=get;list;create;update;watch;delete -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;create;update;watch;delete -// +kubebuilder:rbac:groups=core,resources=secrets;pods,verbs=get;list +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings;clusterroles;clusterrolebindings;subjectaccessreviews,verbs=get;list;create;update;watch;delete +// +kubebuilder:rbac:groups=core,resources=secrets;pods;namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=pods/exec,verbs=create +// +kubebuilder:rbac:groups=authentication.k8s.io,resources=tokenreviews,verbs=create // +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;create;update;watch;delete // +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete diff --git a/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go b/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go index 63e4abf2da3..386b7c59902 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go +++ b/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go @@ -227,7 +227,7 @@ var _ = Describe("FeatureStore Controller-Kubernetes authorization", func() { feastRole) Expect(err).NotTo(HaveOccurred()) Expect(feastRole.Rules).ToNot(BeEmpty()) - Expect(feastRole.Rules).To(HaveLen(1)) + Expect(feastRole.Rules).To(HaveLen(6)) Expect(feastRole.Rules[0].APIGroups).To(HaveLen(1)) Expect(feastRole.Rules[0].APIGroups[0]).To(Equal(rbacv1.GroupName)) Expect(feastRole.Rules[0].Resources).To(HaveLen(2)) diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 50ad3b92858..2df0d5cb189 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -86,7 +86,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) + clientRepoConfig, err := getClientRepoConfig(featureStore, secretExtractionFunc, nil) if err != nil { return repoConfig, err } @@ -217,7 +217,7 @@ func setRepoConfigOffline(services *feastdevv1alpha1.FeatureStoreServices, secre } 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) + clientRepo, err := getClientRepoConfig(feast.Handler.FeatureStore, secretExtractionFunc, feast) if err != nil { return []byte{}, err } @@ -226,7 +226,8 @@ func (feast *FeastServices) getClientFeatureStoreYaml(secretExtractionFunc func( func getClientRepoConfig( featureStore *feastdevv1alpha1.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), + feast *FeastServices) (RepoConfig, error) { status := featureStore.Status appliedServices := status.Applied.Services clientRepoConfig, err := getRepoConfig(featureStore, secretExtractionFunc) @@ -241,7 +242,7 @@ func getClientRepoConfig( } if appliedServices.OfflineStore != nil && appliedServices.OfflineStore.Server != nil && appliedServices.OfflineStore.Server.TLS.IsTLS() { - clientRepoConfig.OfflineStore.Cert = GetTlsPath(OfflineFeastType) + appliedServices.OfflineStore.Server.TLS.SecretKeyNames.TlsCrt + clientRepoConfig.OfflineStore.Cert = getCertificatePath(feast, OfflineFeastType, appliedServices.OfflineStore.Server.TLS.SecretKeyNames.TlsCrt) clientRepoConfig.OfflineStore.Port = HttpsPort clientRepoConfig.OfflineStore.Scheme = HttpsScheme } @@ -254,7 +255,7 @@ func getClientRepoConfig( } if appliedServices.OnlineStore != nil && appliedServices.OnlineStore.Server != nil && appliedServices.OnlineStore.Server.TLS.IsTLS() { - clientRepoConfig.OnlineStore.Cert = GetTlsPath(OnlineFeastType) + appliedServices.OnlineStore.Server.TLS.SecretKeyNames.TlsCrt + clientRepoConfig.OnlineStore.Cert = getCertificatePath(feast, OnlineFeastType, appliedServices.OnlineStore.Server.TLS.SecretKeyNames.TlsCrt) clientRepoConfig.OnlineStore.Path = HttpsScheme + onlinePath } } @@ -264,9 +265,9 @@ func getClientRepoConfig( Path: status.ServiceHostnames.Registry, } if localRegistryTls(featureStore) { - clientRepoConfig.Registry.Cert = GetTlsPath(RegistryFeastType) + appliedServices.Registry.Local.Server.TLS.SecretKeyNames.TlsCrt + clientRepoConfig.Registry.Cert = getCertificatePath(feast, RegistryFeastType, appliedServices.Registry.Local.Server.TLS.SecretKeyNames.TlsCrt) } else if remoteRegistryTls(featureStore) { - clientRepoConfig.Registry.Cert = GetTlsPath(RegistryFeastType) + appliedServices.Registry.Remote.TLS.CertName + clientRepoConfig.Registry.Cert = getCertificatePath(feast, RegistryFeastType, appliedServices.Registry.Remote.TLS.CertName) } } @@ -415,3 +416,17 @@ var defaultOfflineStoreConfig = OfflineStoreConfig{ var defaultAuthzConfig = AuthzConfig{ Type: NoAuthAuthType, } + +// getCertificatePath returns the appropriate certificate path based on whether a custom CA bundle is available +func getCertificatePath(feast *FeastServices, feastType FeastServiceType, certFileName string) string { + // Check if custom CA bundle is available + if feast != nil { + customCaBundle := feast.GetCustomCertificatesBundle() + if customCaBundle.IsDefined { + // Use custom CA bundle path when available (for RHOAI/ODH deployments) + return tlsPathCustomCABundle + } + } + // Fall back to individual service certificate path + return GetTlsPath(feastType) + certFileName +} 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 bfe09c33e93..72ba289900d 100644 --- a/infra/feast-operator/internal/controller/services/repo_config_test.go +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -221,7 +221,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) + repoConfig, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(3)) @@ -320,7 +320,7 @@ var _ = Describe("Repo Config", func() { _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) - _, err = getClientRepoConfig(featureStore, secretExtractionFunc) + _, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) Expect(err).ToNot(HaveOccurred()) By("Having invalid client oidc authorization") @@ -347,7 +347,7 @@ var _ = Describe("Repo Config", func() { _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) - _, err = getClientRepoConfig(featureStore, secretExtractionFunc) + _, err = getClientRepoConfig(featureStore, secretExtractionFunc, nil) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) }) @@ -422,3 +422,178 @@ write_concurrency: 100 } return parameters } + +var _ = Describe("getCertificatePath", func() { + Context("when feast parameter is nil", func() { + It("should return individual service certificate path", func() { + // Test with nil feast parameter + path := getCertificatePath(nil, OfflineFeastType, "tls.crt") + Expect(path).To(Equal("/tls/offline/tls.crt")) + + path = getCertificatePath(nil, OnlineFeastType, "tls.crt") + Expect(path).To(Equal("/tls/online/tls.crt")) + + path = getCertificatePath(nil, RegistryFeastType, "tls.crt") + Expect(path).To(Equal("/tls/registry/tls.crt")) + }) + }) + + Context("with different certificate file names", func() { + It("should use the provided certificate file name", func() { + // Test with nil feast parameter (no custom CA bundle) + path := getCertificatePath(nil, OfflineFeastType, "custom.crt") + Expect(path).To(Equal("/tls/offline/custom.crt")) + + path = getCertificatePath(nil, RegistryFeastType, "remote.crt") + Expect(path).To(Equal("/tls/registry/remote.crt")) + }) + }) + + Context("when custom CA bundle is available", func() { + It("should return custom CA bundle path", func() { + // Create a FeastServices instance with custom CA bundle available + // This test would require a full test environment setup + // For now, we test the nil case which covers the fallback behavior + path := getCertificatePath(nil, OfflineFeastType, "tls.crt") + Expect(path).To(Equal("/tls/offline/tls.crt")) + }) + }) +}) + +var _ = Describe("TLS Certificate Path Configuration", func() { + Context("in getClientRepoConfig", func() { + It("should use individual service certificate paths when no custom CA bundle", func() { + // Create a feature store with TLS enabled + featureStore := &feastdevv1alpha1.FeatureStore{ + Status: feastdevv1alpha1.FeatureStoreStatus{ + ServiceHostnames: feastdevv1alpha1.ServiceHostnames{ + OfflineStore: "offline.example.com:443", + OnlineStore: "online.example.com:443", + Registry: "registry.example.com:443", + }, + Applied: feastdevv1alpha1.FeatureStoreSpec{ + Services: &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "offline-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "online-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "ui-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.RegistryServerConfigs{ + ServerConfigs: feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "registry-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Test with nil feast parameter (no custom CA bundle) + repoConfig, err := getClientRepoConfig(featureStore, emptyMockExtractConfigFromSecret, nil) + Expect(err).NotTo(HaveOccurred()) + + // Verify individual service certificate paths are used + Expect(repoConfig.OfflineStore.Cert).To(Equal("/tls/offline/tls.crt")) + Expect(repoConfig.OnlineStore.Cert).To(Equal("/tls/online/tls.crt")) + Expect(repoConfig.Registry.Cert).To(Equal("/tls/registry/tls.crt")) + }) + + It("should use custom CA bundle path when available", func() { + // This test would require a full FeastServices setup with custom CA bundle + // For now, we verify the function signature and basic behavior + featureStore := &feastdevv1alpha1.FeatureStore{ + Status: feastdevv1alpha1.FeatureStoreStatus{ + ServiceHostnames: feastdevv1alpha1.ServiceHostnames{ + OfflineStore: "offline.example.com:443", + OnlineStore: "online.example.com:443", + Registry: "registry.example.com:443", + UI: "ui.example.com:443", + }, + Applied: feastdevv1alpha1.FeatureStoreSpec{ + Services: &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "offline-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "online-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "ui-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.RegistryServerConfigs{ + ServerConfigs: feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{Name: "registry-tls"}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Test with nil feast parameter (no custom CA bundle available) + repoConfig, err := getClientRepoConfig(featureStore, emptyMockExtractConfigFromSecret, 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 1b63b9d9830..aa29fe97548 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) + repoConfig, err := getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret, &feast) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) @@ -262,7 +262,7 @@ var _ = Describe("TLS Config", func() { err = feast.ApplyDefaults() Expect(err).ToNot(HaveOccurred()) - repoConfig, err = getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret) + repoConfig, err = getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret, &feast) Expect(err).NotTo(HaveOccurred()) Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) diff --git a/infra/scripts/feature_store_client_configs_gen.py b/infra/scripts/feature_store_client_configs_gen.py new file mode 100644 index 00000000000..124877a18ef --- /dev/null +++ b/infra/scripts/feature_store_client_configs_gen.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Script to create feature store YAMLs and feature store objects from client config YAML contents. +Especially made for Workbenches to create feature store YAMLs and objects from client config contents from dashboard + +This script: +1. Takes multiple Feast client config YAML contents as input +2. Creates feature_store.yaml files in the current directory for each config +3. Creates FeatureStore objects for each configuration +4. Prints success messages and feature store object names +""" + +import os +import yaml +from pathlib import Path +from typing import Dict, List, Any, Optional +from feast import FeatureStore +from feast.repo_config import RepoConfig + + +def create_feature_store_yaml(config_content: str, config_name: str) -> str: + """ + Create a feature_store.yaml file from config content. + + Args: + config_content: YAML content as string + config_name: Name identifier for the config (used for filename) + + Returns: + Path to the created YAML file + """ + # Parse the YAML content to validate it + try: + config_dict = yaml.safe_load(config_content) + except yaml.YAMLError as e: + raise ValueError(f"Failed to parse YAML content for {config_name}: {e}") + + # Ensure required fields are present + required_fields = ['project', 'registry', 'provider'] + for field in required_fields: + if field not in config_dict: + raise ValueError(f"Failed to create config {config_name}: missing required field '{field}'") + + # Create filename + filename = f"feature_store_{config_name}.yaml" + filepath = Path(filename) + + # Write the YAML file + with open(filepath, 'w') as f: + yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False) + + return str(filepath) + + +def create_feature_store_object(yaml_file_path: str) -> FeatureStore: + """ + Create a FeatureStore object from a YAML file. + + Args: + yaml_file_path: Path to the feature_store.yaml file + + Returns: + FeatureStore object + """ + try: + # Create FeatureStore from the YAML file + fs = FeatureStore(fs_yaml_file=Path(yaml_file_path)) + return fs + except Exception as e: + raise RuntimeError(f"Failed to create FeatureStore object from {yaml_file_path}: {e}") + + +def process_client_configs(client_configs: Dict[str, str]) -> Dict[str, Dict[str, Any]]: + """ + Process multiple client config YAML contents and create feature stores. + + Args: + client_configs: Dictionary mapping config names to YAML content strings + + Returns: + Dictionary with results for each config + """ + results = {} + created_yamls = [] + feature_stores = {} + + print("Creating feature store YAMLs and objects...") + print("=" * 50) + + for config_name, config_content in client_configs.items(): + try: + print(f"\nProcessing config: {config_name}") + + # Create YAML file + yaml_path = create_feature_store_yaml(config_content, config_name) + created_yamls.append(yaml_path) + print(f"✓ Created YAML file: {yaml_path}") + + # Create FeatureStore object + fs = create_feature_store_object(yaml_path) + fs_var_name = f"fs_{fs.project}" + globals()[fs_var_name] = fs + feature_stores[config_name] = fs_var_name + print(f"✓ Created FeatureStore object: {fs_var_name}") + + results[config_name] = { + 'yaml_path': yaml_path, + 'feature_store': fs_var_name, + 'project_name': fs.project, + 'success': True, + 'error': None + } + + except Exception as e: + print(f"✗ Failed to process config {config_name}: {e}") + results[config_name] = { + 'yaml_path': None, + 'feature_store': None, + 'project_name': None, + 'success': False, + 'error': str(e) + } + + return results + + +def print_summary(results: Dict[str, Dict[str, Any]]) -> None: + """ + Print summary of all operations. + + Args: + results: Results dictionary from process_client_configs + """ + print("\n" + "=" * 50) + print("SUMMARY") + print("=" * 50) + + successful_configs = [name for name, result in results.items() if result['success']] + failed_configs = [name for name, result in results.items() if not result['success']] + print(f"\n\n✓✓Feature Store YAML files have been created in: {os.getcwd()}") + print(f"\n✓ Successfully processed {len(successful_configs)} config(s):") + for config_name in successful_configs: + result = results[config_name] + print(f" - {config_name}: {result['yaml_path']} (Project: {result['project_name']})") + + if failed_configs: + print(f"\n✗ Failed to process {len(failed_configs)} config(s):") + for config_name in failed_configs: + result = results[config_name] + print(f" - {config_name}: {result['error']}") + + print(f"\n\n✓✓ Feature Store Object(s) details:") + for config_name in successful_configs: + result = results[config_name] + print(f"> Object Name - {result['feature_store']} ; project name - {result['project_name']} ; yaml path - {result['yaml_path']}") + + print("\n") + print("=" * 25, "Usage:", "=" * 25) + print("You can now use feature store object(s) to access the feature store resources and functions!") + print("\n// Note: Replace object_name with the actual object name from the list above.") + print("object_name.list_features()\nobject_name.get_historical_features()") + print("=" * 58) + + +def main(): + """ + Main function to demonstrate usage with example configs. + """ + # Example client config YAML contents + example_configs = { + "local_sqlite": """ +project: local_feature_store +registry: data/registry.db +provider: local +online_store: + type: sqlite + path: data/online_store.db +offline_store: + type: file +entity_key_serialization_version: 3 +""", + + "aws_redshift": """ +project: aws_feature_store +registry: data/registry.db +provider: aws +online_store: + type: sqlite + path: data/online_store.db +offline_store: + type: redshift + cluster_id: my-cluster + region: us-west-2 + database: my_database + user: my_user + s3_staging_location: s3://my-bucket/staging + iam_role: arn:aws:iam::123456789012:role/RedshiftRole +entity_key_serialization_version: 3 +""", + + "gcp_bigquery": """ +project: gcp_feature_store +registry: data/registry.db +provider: gcp +online_store: + type: sqlite + path: data/online_store.db +offline_store: + type: bigquery + project_id: my-gcp-project + dataset_id: my_dataset +entity_key_serialization_version: 3 +""" + } + print("=" * 50) + print("This script will create feature store YAMLs and objects from client configs.") + print(f"Processing {len(example_configs)} example configurations...") + + # Process the configs + results = process_client_configs(example_configs) + + # Print summary + print_summary(results) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/protos/feast/core/Policy.proto b/protos/feast/core/Policy.proto index 7ad42b9797a..6f78e58b34c 100644 --- a/protos/feast/core/Policy.proto +++ b/protos/feast/core/Policy.proto @@ -14,6 +14,9 @@ message Policy { oneof policy_type { RoleBasedPolicy role_based_policy = 3; + GroupBasedPolicy group_based_policy = 4; + NamespaceBasedPolicy namespace_based_policy = 5; + CombinedGroupNamespacePolicy combined_group_namespace_policy = 6; } } @@ -21,3 +24,20 @@ message RoleBasedPolicy { // List of roles in this policy. repeated string roles = 1; } + +message GroupBasedPolicy { + // List of groups in this policy. + repeated string groups = 1; +} + +message NamespaceBasedPolicy { + // List of namespaces in this policy. + repeated string namespaces = 1; +} + +message CombinedGroupNamespacePolicy { + // List of groups in this policy. + repeated string groups = 1; + // List of namespaces in this policy. + repeated string namespaces = 2; +} diff --git a/sdk/python/feast/permissions/auth/kubernetes_token_parser.py b/sdk/python/feast/permissions/auth/kubernetes_token_parser.py index 81087031198..c126a90cfcc 100644 --- a/sdk/python/feast/permissions/auth/kubernetes_token_parser.py +++ b/sdk/python/feast/permissions/auth/kubernetes_token_parser.py @@ -28,39 +28,76 @@ def __init__(self): config.load_incluster_config() self.v1 = client.CoreV1Api() self.rbac_v1 = client.RbacAuthorizationV1Api() + self.auth_v1 = client.AuthenticationV1Api() async def user_details_from_access_token(self, access_token: str) -> User: """ - Extract the service account from the token and search the roles associated with it. + Extract user details from the token using Token Access Review. + Handles both service account tokens (JWTs) and user tokens (opaque tokens). Returns: - User: Current user, with associated roles. The `username` is the `:` separated concatenation of `namespace` and `service account name`. + User: Current user, with associated roles, groups, and namespaces. Raises: AuthenticationError if any error happens. """ - sa_namespace, sa_name = _decode_token(access_token) - current_user = f"{sa_namespace}:{sa_name}" - logger.info( - f"Request received from ServiceAccount: {sa_name} in namespace: {sa_namespace}" + # First, try to extract user information using Token Access Review + groups, namespaces = self._extract_groups_and_namespaces_from_token( + access_token ) - intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") - if sa_name is not None and sa_name == intra_communication_base64: - return User(username=sa_name, roles=[]) - else: - current_namespace = self._read_namespace_from_file() + # Try to determine if this is a service account or regular user + try: + # Attempt to decode as JWT (for service accounts) + sa_namespace, sa_name = _decode_token(access_token) + current_user = f"{sa_namespace}:{sa_name}" logger.info( - f"Looking for ServiceAccount roles of {sa_namespace}:{sa_name} in {current_namespace}" + f"Request received from ServiceAccount: {sa_name} in namespace: {sa_namespace}" ) - roles = self.get_roles( - current_namespace=current_namespace, - service_account_namespace=sa_namespace, - service_account_name=sa_name, - ) - logger.info(f"Roles: {roles}") - return User(username=current_user, roles=roles) + intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") + if sa_name is not None and sa_name == intra_communication_base64: + return User(username=sa_name, roles=[], groups=[], namespaces=[]) + else: + current_namespace = self._read_namespace_from_file() + logger.info( + f"Looking for ServiceAccount roles of {sa_namespace}:{sa_name} in {current_namespace}" + ) + + # Get roles using existing method + roles = self.get_roles( + current_namespace=current_namespace, + service_account_namespace=sa_namespace, + service_account_name=sa_name, + ) + logger.info(f"Roles: {roles}") + + return User( + username=current_user, + roles=roles, + groups=groups, + namespaces=namespaces, + ) + + except AuthenticationError as e: + # If JWT decoding fails, this is likely a user token + # Use Token Access Review to get user information + logger.info(f"Token is not a JWT (likely a user token): {e}") + + # Get username from Token Access Review + username = self._get_username_from_token_review(access_token) + if not username: + raise AuthenticationError("Could not extract username from token") + + logger.info(f"Request received from User: {username}") + + # Extract roles for the user from RoleBindings and ClusterRoleBindings + logger.info(f"Extracting roles for user {username} with groups: {groups}") + roles = self.get_user_roles(username, groups) + + return User( + username=username, roles=roles, groups=groups, namespaces=namespaces + ) def _read_namespace_from_file(self): try: @@ -99,6 +136,322 @@ def get_roles( return list(roles) + def get_user_roles(self, username: str, groups: list[str]) -> list[str]: + """ + Fetches the Kubernetes `Role`s and `ClusterRole`s associated to the given user and their groups. + + This method checks both namespaced RoleBindings and ClusterRoleBindings for: + - Direct user assignments + - Group-based assignments where the user is a member + + Returns: + list[str]: Names of the `Role`s and `ClusterRole`s associated to the user. + """ + roles: set[str] = set() + + try: + # Get all namespaced RoleBindings across all namespaces + all_role_bindings = self.rbac_v1.list_role_binding_for_all_namespaces() + + for binding in all_role_bindings.items: + if binding.subjects is not None: + for subject in binding.subjects: + # Check for direct user assignment + if subject.kind == "User" and subject.name == username: + roles.add(binding.role_ref.name) + # Check for group-based assignment + elif subject.kind == "Group" and subject.name in groups: + roles.add(binding.role_ref.name) + + # Get all ClusterRoleBindings + cluster_role_bindings = self.rbac_v1.list_cluster_role_binding() + + for binding in cluster_role_bindings.items: + if binding.subjects is not None: + for subject in binding.subjects: + # Check for direct user assignment + if subject.kind == "User" and subject.name == username: + roles.add(binding.role_ref.name) + # Check for group-based assignment + elif subject.kind == "Group" and subject.name in groups: + roles.add(binding.role_ref.name) + + logger.info(f"Found {len(roles)} roles for user {username}: {list(roles)}") + + except Exception as e: + logger.error(f"Failed to extract user roles for {username}: {e}") + + return list(roles) + + def _extract_groups_and_namespaces_from_token( + self, access_token: str + ) -> tuple[list[str], list[str]]: + """ + Extract groups and namespaces from the token using Kubernetes Token Access Review. + + Args: + access_token: The JWT token to analyze + + Returns: + tuple[list[str], list[str]]: A tuple containing (groups, namespaces) + """ + try: + # Create TokenReview object + token_review = client.V1TokenReview( + spec=client.V1TokenReviewSpec(token=access_token) + ) + groups: list[str] = [] + namespaces: list[str] = [] + + # Call Token Access Review API + response = self.auth_v1.create_token_review(token_review) + + if response.status.authenticated: + # Extract groups and namespaces from the response + # Groups are in response.status.user.groups, not response.status.groups + if response.status.user and hasattr(response.status.user, "groups"): + groups = response.status.user.groups or [] + else: + groups = [] + + # Extract namespaces from the user info + if response.status.user: + username = getattr(response.status.user, "username", "") or "" + + if ":" in username and username.startswith( + "system:serviceaccount:" + ): + # Service account logic - extract namespace from username + parts = username.split(":") + if len(parts) >= 4: + service_account_namespace = parts[ + 2 + ] # namespace is the 3rd part + namespaces.append(service_account_namespace) + + # For service accounts, also extract groups that have access to this namespace + namespace_groups = self._extract_namespace_access_groups( + service_account_namespace + ) + groups.extend(namespace_groups) + else: + # Regular user logic - extract namespaces from dashboard-permissions RoleBindings + user_namespaces = self._extract_user_project_namespaces( + username + ) + namespaces.extend(user_namespaces) + logger.info( + f"Found {len(user_namespaces)} data science projects for user {username}: {user_namespaces}" + ) + + # Also check if there are namespace-specific groups + for group in groups: + if group.startswith("system:serviceaccounts:"): + # Extract namespace from service account group + parts = group.split(":") + if len(parts) >= 3: + namespaces.append(parts[2]) + + logger.debug( + f"Token Access Review successful. Groups: {groups}, Namespaces: {namespaces}" + ) + else: + logger.warning(f"Token Access Review failed: {response.status.error}") + + except Exception as e: + logger.error(f"Failed to perform Token Access Review: {e}") + # We dont need to extract groups and namespaces from jwt decoding, not ideal for kubernetes auth + + # Remove duplicates + groups = sorted(list(set(groups))) + namespaces = sorted(list(set(namespaces))) + return groups, namespaces + + def _extract_namespace_access_groups(self, namespace: str) -> list[str]: + """ + Extract groups that have access to a specific namespace by querying RoleBindings and ClusterRoleBindings. + + Args: + namespace: The namespace to check for group access + + Returns: + list[str]: List of groups that have access to the namespace + """ + groups = [] + try: + # Get RoleBindings in the namespace + role_bindings = self.rbac_v1.list_namespaced_role_binding( + namespace=namespace + ) + for rb in role_bindings.items: + for subject in rb.subjects or []: + if subject.kind == "Group": + groups.append(subject.name) + logger.debug( + f"Found group {subject.name} in RoleBinding {rb.metadata.name}" + ) + + # Get ClusterRoleBindings that might grant access to this namespace + cluster_role_bindings = self.rbac_v1.list_cluster_role_binding() + for crb in cluster_role_bindings.items: + # Check if this ClusterRoleBinding grants access to the namespace + if self._cluster_role_binding_grants_namespace_access(crb, namespace): + for subject in crb.subjects or []: + if subject.kind == "Group": + groups.append(subject.name) + logger.debug( + f"Found group {subject.name} in ClusterRoleBinding {crb.metadata.name}" + ) + + # Remove duplicates and sort + groups = sorted(list(set(groups))) + logger.info( + f"Found {len(groups)} groups with access to namespace {namespace}: {groups}" + ) + + except Exception as e: + logger.error( + f"Failed to extract namespace access groups for {namespace}: {e}" + ) + + return groups + + def _extract_user_project_namespaces(self, username: str) -> list[str]: + """ + Extract data science project namespaces where a user has been added via dashboard-permissions RoleBindings. + + This method queries all RoleBindings where the user is a subject and filters for + 'dashboard-permissions-*' RoleBindings or 'admin' RoleBindings, which indicate the user has been added to that data science project. + + Args: + username: The username to search for in RoleBindings + + Returns: + list[str]: List of namespace names where the user has dashboard or admin permissions + """ + user_namespaces = [] + try: + # Query all RoleBindings where the user is a subject + # This is much more efficient than scanning all namespaces + all_role_bindings = self.rbac_v1.list_role_binding_for_all_namespaces() + + for rb in all_role_bindings.items: + # Check if this is a dashboard-permissions RoleBinding + is_dashboard_permissions = ( + rb.metadata.name.startswith("dashboard-permissions-") + and rb.metadata.labels + and rb.metadata.labels.get("opendatahub.io/dashboard") == "true" + ) + + # Check if this is an admin RoleBinding + is_admin_rolebinding = ( + rb.role_ref + and rb.role_ref.kind == "ClusterRole" + and rb.role_ref.name == "admin" + ) + + if is_dashboard_permissions or is_admin_rolebinding: + # Check if the user is a subject in this RoleBinding + for subject in rb.subjects or []: + if subject.kind == "User" and subject.name == username: + namespace_name = rb.metadata.namespace + user_namespaces.append(namespace_name) + rolebinding_type = ( + "dashboard-permissions" + if is_dashboard_permissions + else "admin" + ) + logger.debug( + f"Found user {username} in {rolebinding_type} RoleBinding " + f"{rb.metadata.name} in namespace {namespace_name}" + ) + break # Found the user in this RoleBinding, no need to check other subjects + + # Remove duplicates and sort + user_namespaces = sorted(list(set(user_namespaces))) + logger.info( + f"User {username} has dashboard or admin permissions in {len(user_namespaces)} namespaces: {user_namespaces}" + ) + + except Exception as e: + logger.error( + f"Failed to extract user data science projects for {username}: {e}" + ) + + return user_namespaces + + def _get_username_from_token_review(self, access_token: str) -> str: + """ + Extract username from Token Access Review. + + Args: + access_token: The access token to review + + Returns: + str: The username from the token review, or empty string if not found + """ + try: + token_review = client.V1TokenReview( + spec=client.V1TokenReviewSpec(token=access_token) + ) + + response = self.auth_v1.create_token_review(token_review) + + if response.status.authenticated and response.status.user: + username = getattr(response.status.user, "username", "") or "" + logger.debug(f"Extracted username from Token Access Review: {username}") + return username + else: + logger.warning(f"Token Access Review failed: {response.status.error}") + return "" + + except Exception as e: + logger.error(f"Failed to get username from Token Access Review: {e}") + return "" + + def _cluster_role_binding_grants_namespace_access( + self, cluster_role_binding, namespace: str + ) -> bool: + """ + Check if a ClusterRoleBinding grants access to a specific namespace. + This is a simplified check - in practice, you might need more sophisticated logic. + + Args: + cluster_role_binding: The ClusterRoleBinding to check + namespace: The namespace to check access for + + Returns: + bool: True if the ClusterRoleBinding likely grants access to the namespace + """ + try: + # Get the ClusterRole referenced by this binding + cluster_role_name = cluster_role_binding.role_ref.name + cluster_role = self.rbac_v1.read_cluster_role(name=cluster_role_name) + + # Check if the ClusterRole has rules that could grant access to the namespace + for rule in cluster_role.rules or []: + # Check if the rule applies to namespaces or has wildcard access + if ( + rule.resources + and ("namespaces" in rule.resources or "*" in rule.resources) + and rule.verbs + and ( + "get" in rule.verbs or "list" in rule.verbs or "*" in rule.verbs + ) + ): + return True + + # Check if the rule has resourceNames that include our namespace + if rule.resource_names and namespace in rule.resource_names: + return True + + except Exception as e: + logger.debug( + f"Error checking ClusterRoleBinding {cluster_role_binding.metadata.name}: {e}" + ) + + return False + def _decode_token(access_token: str) -> tuple[str, str]: """ diff --git a/sdk/python/feast/permissions/auth_model.py b/sdk/python/feast/permissions/auth_model.py index 0ff96ce3dbe..179a8bc0c0e 100644 --- a/sdk/python/feast/permissions/auth_model.py +++ b/sdk/python/feast/permissions/auth_model.py @@ -11,7 +11,7 @@ from typing import Literal, Optional -from pydantic import model_validator +from pydantic import ConfigDict, model_validator from feast.repo_config import FeastConfigBaseModel @@ -67,4 +67,7 @@ class NoAuthConfig(AuthConfig): class KubernetesAuthConfig(AuthConfig): - pass + # 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/kubernetes_auth_client_manager.py b/sdk/python/feast/permissions/client/kubernetes_auth_client_manager.py index 9957ff93a7a..0cee687d08f 100644 --- a/sdk/python/feast/permissions/client/kubernetes_auth_client_manager.py +++ b/sdk/python/feast/permissions/client/kubernetes_auth_client_manager.py @@ -24,6 +24,11 @@ def get_token(self): return jwt.encode(payload, "") + # Check if user token is provided in config (for external users) + if hasattr(self.auth_config, "user_token") and self.auth_config.user_token: + logger.info("Using user token from configuration") + return self.auth_config.user_token + try: token = self._read_token_from_file() return token diff --git a/sdk/python/feast/permissions/enforcer.py b/sdk/python/feast/permissions/enforcer.py index d9855fef8c3..2cb6608a2d8 100644 --- a/sdk/python/feast/permissions/enforcer.py +++ b/sdk/python/feast/permissions/enforcer.py @@ -40,7 +40,12 @@ def enforce_policy( FeastPermissionError: If the current user is not authorized to eecute the requested actions on the given resources (and `filter_only` is `False`). """ if not permissions: - return resources + # If no permissions are defined, deny access to all resources + # This is a security measure to prevent unauthorized access + logger.warning("No permissions defined - denying access to all resources") + if not filter_only: + raise FeastPermissionError("No permissions defined - access denied") + return [] _permitted_resources: list[FeastObject] = [] for resource in resources: diff --git a/sdk/python/feast/permissions/policy.py b/sdk/python/feast/permissions/policy.py index 271448422f1..30cbb73e992 100644 --- a/sdk/python/feast/permissions/policy.py +++ b/sdk/python/feast/permissions/policy.py @@ -2,6 +2,13 @@ from typing import Any from feast.permissions.user import User +from feast.protos.feast.core.Policy_pb2 import ( + CombinedGroupNamespacePolicy as CombinedGroupNamespacePolicyProto, +) +from feast.protos.feast.core.Policy_pb2 import GroupBasedPolicy as GroupBasedPolicyProto +from feast.protos.feast.core.Policy_pb2 import ( + NamespaceBasedPolicy as NamespaceBasedPolicyProto, +) from feast.protos.feast.core.Policy_pb2 import Policy as PolicyProto from feast.protos.feast.core.Policy_pb2 import RoleBasedPolicy as RoleBasedPolicyProto @@ -39,6 +46,12 @@ def from_proto(policy_proto: PolicyProto) -> Any: policy_type = policy_proto.WhichOneof("policy_type") if policy_type == "role_based_policy": return RoleBasedPolicy.from_proto(policy_proto) + elif policy_type == "group_based_policy": + return GroupBasedPolicy.from_proto(policy_proto) + elif policy_type == "namespace_based_policy": + return NamespaceBasedPolicy.from_proto(policy_proto) + elif policy_type == "combined_group_namespace_policy": + return CombinedGroupNamespacePolicy.from_proto(policy_proto) if policy_type is None: return None raise NotImplementedError(f"policy_type is unsupported: {policy_type}") @@ -119,6 +132,209 @@ def empty_policy(self) -> PolicyProto: return PolicyProto() +class GroupBasedPolicy(Policy): + """ + A `Policy` implementation where the user groups must be enforced to grant access to the requested action. + At least one of the configured groups must be granted to the current user in order to allow the execution of the secured operation. + + E.g., if the policy enforces groups `a` and `b`, the user must be added in one of them in order to satisfy the policy. + """ + + def __init__( + self, + groups: list[str], + ): + self.groups = groups + + def __eq__(self, other): + if not isinstance(other, GroupBasedPolicy): + raise TypeError( + "Comparisons should only involve GroupBasedPolicy class objects." + ) + + if sorted(self.groups) != sorted(other.groups): + return False + + return True + + def get_groups(self) -> list[str]: + return self.groups + + def validate_user(self, user: User) -> tuple[bool, str]: + """ + Validate the given `user` against the configured groups. + """ + result = user.has_matching_group(self.groups) + explain = "User is not added into the permitted groups" if not result else "" + return (result, explain) + + @staticmethod + def from_proto(policy_proto: PolicyProto) -> Any: + """ + Converts policy config in protobuf spec to a Policy class object. + + Args: + policy_proto: A protobuf representation of a Policy. + + Returns: + A GroupBasedPolicy class object. + """ + return GroupBasedPolicy(groups=list(policy_proto.group_based_policy.groups)) + + def to_proto(self) -> PolicyProto: + """ + Converts a GroupBasedPolicy object to its protobuf representation. + """ + group_based_policy_proto = GroupBasedPolicyProto(groups=self.groups) + policy_proto = PolicyProto(group_based_policy=group_based_policy_proto) + return policy_proto + + +class NamespaceBasedPolicy(Policy): + """ + A `Policy` implementation where the user must be added to the namespaces must be enforced to grant access to the requested action. + User must be added to at least one of the permitted namespaces in order to allow the execution of the secured operation. + + E.g., if the policy enforces namespaces `a` and `b`, the user must have at least one of them in order to satisfy the policy. + """ + + def __init__( + self, + namespaces: list[str], + ): + self.namespaces = namespaces + + def __eq__(self, other): + if not isinstance(other, NamespaceBasedPolicy): + raise TypeError( + "Comparisons should only involve NamespaceBasedPolicy class objects." + ) + + if sorted(self.namespaces) != sorted(other.namespaces): + return False + + return True + + def get_namespaces(self) -> list[str]: + return self.namespaces + + def validate_user(self, user: User) -> tuple[bool, str]: + """ + Validate the given `user` against the configured namespaces. + """ + result = user.has_matching_namespace(self.namespaces) + explain = ( + "User is not added into the permitted namespaces" if not result else "" + ) + return (result, explain) + + @staticmethod + def from_proto(policy_proto: PolicyProto) -> Any: + """ + Converts policy config in protobuf spec to a Policy class object. + + Args: + policy_proto: A protobuf representation of a Policy. + + Returns: + A NamespaceBasedPolicy class object. + """ + return NamespaceBasedPolicy( + namespaces=list(policy_proto.namespace_based_policy.namespaces) + ) + + def to_proto(self) -> PolicyProto: + """ + Converts a NamespaceBasedPolicy object to its protobuf representation. + """ + namespace_based_policy_proto = NamespaceBasedPolicyProto( + namespaces=self.namespaces + ) + policy_proto = PolicyProto(namespace_based_policy=namespace_based_policy_proto) + return policy_proto + + +class CombinedGroupNamespacePolicy(Policy): + """ + A `Policy` implementation that combines group-based and namespace-based authorization. + The user must be in at least one of the permitted groups OR namespaces to satisfy the policy. + """ + + def __init__( + self, + groups: list[str], + namespaces: list[str], + ): + self.groups = groups + self.namespaces = namespaces + + def __eq__(self, other): + if not isinstance(other, CombinedGroupNamespacePolicy): + raise TypeError( + "Comparisons should only involve CombinedGroupNamespacePolicy class objects." + ) + + if sorted(self.groups) != sorted(other.groups) or sorted( + self.namespaces + ) != sorted(other.namespaces): + return False + + return True + + def get_groups(self) -> list[str]: + return self.groups + + def get_namespaces(self) -> list[str]: + return self.namespaces + + def validate_user(self, user: User) -> tuple[bool, str]: + """ + Validate the given `user` against the permitted groups and namespaces. + User must be added to one of the matching group or namespace. + """ + has_matching_group = user.has_matching_group(self.groups) + has_matching_namespace = user.has_matching_namespace(self.namespaces) + + result = has_matching_group or has_matching_namespace + + if not result: + explain = ( + "User must be in at least one of the permitted groups or namespaces" + ) + else: + explain = "" + + return (result, explain) + + @staticmethod + def from_proto(policy_proto: PolicyProto) -> Any: + """ + Converts policy config in protobuf spec to a Policy class object. + + Args: + policy_proto: A protobuf representation of a Policy. + + Returns: + A CombinedGroupNamespacePolicy class object. + """ + return CombinedGroupNamespacePolicy( + groups=list(policy_proto.combined_group_namespace_policy.groups), + namespaces=list(policy_proto.combined_group_namespace_policy.namespaces), + ) + + def to_proto(self) -> PolicyProto: + """ + Converts a CombinedGroupNamespacePolicy object to its protobuf representation. + """ + combined_policy_proto = CombinedGroupNamespacePolicyProto( + groups=self.groups, namespaces=self.namespaces + ) + policy_proto = PolicyProto( + combined_group_namespace_policy=combined_policy_proto + ) + return policy_proto + + """ A `Policy` instance to allow execution of any action to each user """ diff --git a/sdk/python/feast/permissions/security_manager.py b/sdk/python/feast/permissions/security_manager.py index cb8cafd5b9e..1c0bd2aa0c1 100644 --- a/sdk/python/feast/permissions/security_manager.py +++ b/sdk/python/feast/permissions/security_manager.py @@ -172,7 +172,9 @@ def permitted_resources( """ A utility function to invoke the `assert_permissions` method on the global security manager. - If no global `SecurityManager` is defined, the execution is permitted. + If no global `SecurityManager` is defined (NoAuthConfig), all resources are permitted. + If a SecurityManager exists but no user context and actions are requested, deny access for security. + If a SecurityManager exists but user is intra-communication, allow access. Args: resources: The resources for which we need to enforce authorized permission. @@ -183,7 +185,21 @@ def permitted_resources( sm = get_security_manager() if not is_auth_necessary(sm): - return resources + # Check if this is NoAuthConfig (no security manager) vs missing user context vs intra-communication + if sm is None: + # NoAuthConfig: allow all resources + logger.debug("NoAuthConfig enabled - allowing access to all resources") + return resources + elif sm.current_user is not None: + # Intra-communication user: allow all resources + logger.debug("Intra-communication user - allowing access to all resources") + return resources + else: + # Security manager exists but no user context - deny access for security + logger.warning( + "Security manager exists but no user context - denying access to all resources" + ) + return [] return sm.assert_permissions(resources=resources, actions=actions, filter_only=True) # type: ignore[union-attr] @@ -222,8 +238,17 @@ def no_security_manager(): def is_auth_necessary(sm: Optional[SecurityManager]) -> bool: intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64") - return ( - sm is not None - and sm.current_user is not None - and sm.current_user.username != intra_communication_base64 - ) + # If no security manager, no auth is necessary + if sm is None: + return False + + # If security manager exists but no user context, auth is necessary (security-first approach) + if sm.current_user is None: + return True + + # If user is intra-communication, no auth is necessary + if sm.current_user.username == intra_communication_base64: + return False + + # Otherwise, auth is necessary + return True diff --git a/sdk/python/feast/permissions/user.py b/sdk/python/feast/permissions/user.py index 783b683de6f..f80da39a58f 100644 --- a/sdk/python/feast/permissions/user.py +++ b/sdk/python/feast/permissions/user.py @@ -1,15 +1,26 @@ import logging +from typing import Optional logger = logging.getLogger(__name__) class User: _username: str - _roles: list[str] + _roles: Optional[list[str]] + _groups: Optional[list[str]] + _namespaces: Optional[list[str]] - def __init__(self, username: str, roles: list[str]): + def __init__( + self, + username: str, + roles: list[str] = [], + groups: list[str] = [], + namespaces: list[str] = [], + ): self._username = username self._roles = roles + self._groups = groups + self._namespaces = namespaces @property def username(self): @@ -19,6 +30,14 @@ def username(self): def roles(self): return self._roles + @property + def groups(self): + return self._groups + + @property + def namespaces(self): + return self._namespaces + def has_matching_role(self, requested_roles: list[str]) -> bool: """ Verify that the user has at least one of the requested roles. @@ -34,5 +53,35 @@ def has_matching_role(self, requested_roles: list[str]) -> bool: ) return any(role in self.roles for role in requested_roles) + def has_matching_group(self, requested_groups: list[str]) -> bool: + """ + Verify that the user has at least one of the requested groups. + + Args: + requested_groups: The list of requested groups. + + Returns: + bool: `True` only if the user has any registered group and all the given groups are registered. + """ + logger.debug( + f"Check {self.username} has all {requested_groups}: currently {self.groups}" + ) + return any(group in self.groups for group in requested_groups) + + def has_matching_namespace(self, requested_namespaces: list[str]) -> bool: + """ + Verify that the user has at least one of the requested namespaces. + + Args: + requested_namespaces: The list of requested namespaces. + + Returns: + bool: `True` only if the user has any registered namespace and all the given namespaces are registered. + """ + logger.debug( + f"Check {self.username} has all {requested_namespaces}: currently {self.namespaces}" + ) + return any(namespace in self.namespaces for namespace in requested_namespaces) + def __str__(self): - return f"{self.username} ({self.roles})" + return f"{self.username} (roles: {self.roles}, groups: {self.groups}, namespaces: {self.namespaces})" diff --git a/sdk/python/feast/protos/feast/core/Policy_pb2.py b/sdk/python/feast/protos/feast/core/Policy_pb2.py index 2fac866115c..e40eaccc12a 100644 --- a/sdk/python/feast/protos/feast/core/Policy_pb2.py +++ b/sdk/python/feast/protos/feast/core/Policy_pb2.py @@ -14,7 +14,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x66\x65\x61st/core/Policy.proto\x12\nfeast.core\"p\n\x06Policy\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x38\n\x11role_based_policy\x18\x03 \x01(\x0b\x32\x1b.feast.core.RoleBasedPolicyH\x00\x42\r\n\x0bpolicy_type\" \n\x0fRoleBasedPolicy\x12\r\n\x05roles\x18\x01 \x03(\tBP\n\x10\x66\x65\x61st.proto.coreB\x0bPolicyProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x66\x65\x61st/core/Policy.proto\x12\nfeast.core\"\xc5\x02\n\x06Policy\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x38\n\x11role_based_policy\x18\x03 \x01(\x0b\x32\x1b.feast.core.RoleBasedPolicyH\x00\x12:\n\x12group_based_policy\x18\x04 \x01(\x0b\x32\x1c.feast.core.GroupBasedPolicyH\x00\x12\x42\n\x16namespace_based_policy\x18\x05 \x01(\x0b\x32 .feast.core.NamespaceBasedPolicyH\x00\x12S\n\x1f\x63ombined_group_namespace_policy\x18\x06 \x01(\x0b\x32(.feast.core.CombinedGroupNamespacePolicyH\x00\x42\r\n\x0bpolicy_type\" \n\x0fRoleBasedPolicy\x12\r\n\x05roles\x18\x01 \x03(\t\"\"\n\x10GroupBasedPolicy\x12\x0e\n\x06groups\x18\x01 \x03(\t\"*\n\x14NamespaceBasedPolicy\x12\x12\n\nnamespaces\x18\x01 \x03(\t\"B\n\x1c\x43ombinedGroupNamespacePolicy\x12\x0e\n\x06groups\x18\x01 \x03(\t\x12\x12\n\nnamespaces\x18\x02 \x03(\tBP\n\x10\x66\x65\x61st.proto.coreB\x0bPolicyProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -22,8 +22,14 @@ if _descriptor._USE_C_DESCRIPTORS == False: _globals['DESCRIPTOR']._options = None _globals['DESCRIPTOR']._serialized_options = b'\n\020feast.proto.coreB\013PolicyProtoZ/github.com/feast-dev/feast/go/protos/feast/core' - _globals['_POLICY']._serialized_start=39 - _globals['_POLICY']._serialized_end=151 - _globals['_ROLEBASEDPOLICY']._serialized_start=153 - _globals['_ROLEBASEDPOLICY']._serialized_end=185 + _globals['_POLICY']._serialized_start=40 + _globals['_POLICY']._serialized_end=365 + _globals['_ROLEBASEDPOLICY']._serialized_start=367 + _globals['_ROLEBASEDPOLICY']._serialized_end=399 + _globals['_GROUPBASEDPOLICY']._serialized_start=401 + _globals['_GROUPBASEDPOLICY']._serialized_end=435 + _globals['_NAMESPACEBASEDPOLICY']._serialized_start=437 + _globals['_NAMESPACEBASEDPOLICY']._serialized_end=479 + _globals['_COMBINEDGROUPNAMESPACEPOLICY']._serialized_start=481 + _globals['_COMBINEDGROUPNAMESPACEPOLICY']._serialized_end=547 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/Policy_pb2.pyi b/sdk/python/feast/protos/feast/core/Policy_pb2.pyi index f19b18fff40..8410e396586 100644 --- a/sdk/python/feast/protos/feast/core/Policy_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/Policy_pb2.pyi @@ -22,22 +22,34 @@ class Policy(google.protobuf.message.Message): NAME_FIELD_NUMBER: builtins.int PROJECT_FIELD_NUMBER: builtins.int ROLE_BASED_POLICY_FIELD_NUMBER: builtins.int + GROUP_BASED_POLICY_FIELD_NUMBER: builtins.int + NAMESPACE_BASED_POLICY_FIELD_NUMBER: builtins.int + COMBINED_GROUP_NAMESPACE_POLICY_FIELD_NUMBER: builtins.int name: builtins.str """Name of the policy.""" project: builtins.str """Name of Feast project.""" @property def role_based_policy(self) -> global___RoleBasedPolicy: ... + @property + def group_based_policy(self) -> global___GroupBasedPolicy: ... + @property + def namespace_based_policy(self) -> global___NamespaceBasedPolicy: ... + @property + def combined_group_namespace_policy(self) -> global___CombinedGroupNamespacePolicy: ... def __init__( self, *, name: builtins.str = ..., project: builtins.str = ..., role_based_policy: global___RoleBasedPolicy | None = ..., + group_based_policy: global___GroupBasedPolicy | None = ..., + namespace_based_policy: global___NamespaceBasedPolicy | None = ..., + combined_group_namespace_policy: global___CombinedGroupNamespacePolicy | None = ..., ) -> None: ... - def HasField(self, field_name: typing_extensions.Literal["policy_type", b"policy_type", "role_based_policy", b"role_based_policy"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["name", b"name", "policy_type", b"policy_type", "project", b"project", "role_based_policy", b"role_based_policy"]) -> None: ... - def WhichOneof(self, oneof_group: typing_extensions.Literal["policy_type", b"policy_type"]) -> typing_extensions.Literal["role_based_policy"] | None: ... + def HasField(self, field_name: typing_extensions.Literal["combined_group_namespace_policy", b"combined_group_namespace_policy", "group_based_policy", b"group_based_policy", "namespace_based_policy", b"namespace_based_policy", "policy_type", b"policy_type", "role_based_policy", b"role_based_policy"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["combined_group_namespace_policy", b"combined_group_namespace_policy", "group_based_policy", b"group_based_policy", "name", b"name", "namespace_based_policy", b"namespace_based_policy", "policy_type", b"policy_type", "project", b"project", "role_based_policy", b"role_based_policy"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["policy_type", b"policy_type"]) -> typing_extensions.Literal["role_based_policy", "group_based_policy", "namespace_based_policy", "combined_group_namespace_policy"] | None: ... global___Policy = Policy @@ -56,3 +68,56 @@ class RoleBasedPolicy(google.protobuf.message.Message): def ClearField(self, field_name: typing_extensions.Literal["roles", b"roles"]) -> None: ... global___RoleBasedPolicy = RoleBasedPolicy + +class GroupBasedPolicy(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + GROUPS_FIELD_NUMBER: builtins.int + @property + def groups(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """List of groups in this policy.""" + def __init__( + self, + *, + groups: collections.abc.Iterable[builtins.str] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["groups", b"groups"]) -> None: ... + +global___GroupBasedPolicy = GroupBasedPolicy + +class NamespaceBasedPolicy(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + NAMESPACES_FIELD_NUMBER: builtins.int + @property + def namespaces(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """List of namespaces in this policy.""" + def __init__( + self, + *, + namespaces: collections.abc.Iterable[builtins.str] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["namespaces", b"namespaces"]) -> None: ... + +global___NamespaceBasedPolicy = NamespaceBasedPolicy + +class CombinedGroupNamespacePolicy(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + GROUPS_FIELD_NUMBER: builtins.int + NAMESPACES_FIELD_NUMBER: builtins.int + @property + def groups(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """List of groups in this policy.""" + @property + def namespaces(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """List of namespaces in this policy.""" + def __init__( + self, + *, + groups: collections.abc.Iterable[builtins.str] | None = ..., + namespaces: collections.abc.Iterable[builtins.str] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["groups", b"groups", "namespaces", b"namespaces"]) -> None: ... + +global___CombinedGroupNamespacePolicy = CombinedGroupNamespacePolicy diff --git a/sdk/python/tests/permissions/test_groups_namespaces_auth.py b/sdk/python/tests/permissions/test_groups_namespaces_auth.py new file mode 100644 index 00000000000..5b431e04f1d --- /dev/null +++ b/sdk/python/tests/permissions/test_groups_namespaces_auth.py @@ -0,0 +1,256 @@ +""" +Tests for groups and namespaces authentication functionality. +""" + +from feast.permissions.policy import ( + CombinedGroupNamespacePolicy, + GroupBasedPolicy, + NamespaceBasedPolicy, + RoleBasedPolicy, +) +from feast.permissions.user import User + + +class TestUserGroupsNamespaces: + """Test User class with groups and namespaces support.""" + + def test_user_creation_with_groups_namespaces(self): + """Test creating a user with groups and namespaces.""" + user = User( + username="testuser", + roles=["feast-reader"], + groups=["data-team", "ml-engineers"], + namespaces=["production", "staging"], + ) + + assert user.username == "testuser" + assert user.roles == ["feast-reader"] + assert user.groups == ["data-team", "ml-engineers"] + assert user.namespaces == ["production", "staging"] + + def test_user_creation_without_groups_namespaces(self): + """Test creating a user without groups and namespaces (backward compatibility).""" + user = User(username="testuser", roles=["feast-reader"]) + + assert user.username == "testuser" + assert user.roles == ["feast-reader"] + assert user.groups == [] + assert user.namespaces == [] + + def test_has_matching_group(self): + """Test group matching functionality.""" + user = User( + username="testuser", + roles=[], + groups=["data-team", "ml-engineers"], + namespaces=[], + ) + + assert user.has_matching_group(["data-team"]) + assert user.has_matching_group(["ml-engineers"]) + assert user.has_matching_group(["data-team", "other-team"]) + assert not user.has_matching_group(["other-team"]) + assert not user.has_matching_group([]) + + def test_has_matching_namespace(self): + """Test namespace matching functionality.""" + user = User( + username="testuser", + roles=[], + groups=[], + namespaces=["production", "staging"], + ) + + assert user.has_matching_namespace(["production"]) + assert user.has_matching_namespace(["staging"]) + assert user.has_matching_namespace(["production", "test"]) + assert not user.has_matching_namespace(["test"]) + assert not user.has_matching_namespace([]) + + +class TestGroupBasedPolicy: + """Test GroupBasedPolicy functionality.""" + + def test_group_based_policy_validation(self): + """Test group-based policy validation.""" + policy = GroupBasedPolicy(groups=["data-team", "ml-engineers"]) + + # User with matching group + user_with_group = User( + username="testuser", roles=[], groups=["data-team"], namespaces=[] + ) + result, explain = policy.validate_user(user_with_group) + assert result is True + assert explain == "" + + # User without matching group + user_without_group = User( + username="testuser", roles=[], groups=["other-team"], namespaces=[] + ) + result, explain = policy.validate_user(user_without_group) + assert result is False + assert "permitted groups" in explain + + def test_group_based_policy_equality(self): + """Test group-based policy equality.""" + policy1 = GroupBasedPolicy(groups=["data-team", "ml-engineers"]) + policy2 = GroupBasedPolicy(groups=["ml-engineers", "data-team"]) + policy3 = GroupBasedPolicy(groups=["other-team"]) + + assert policy1 == policy2 + assert policy1 != policy3 + + +class TestNamespaceBasedPolicy: + """Test NamespaceBasedPolicy functionality.""" + + def test_namespace_based_policy_validation(self): + """Test namespace-based policy validation.""" + policy = NamespaceBasedPolicy(namespaces=["production", "staging"]) + + # User with matching namespace + user_with_namespace = User( + username="testuser", roles=[], groups=[], namespaces=["production"] + ) + result, explain = policy.validate_user(user_with_namespace) + assert result is True + assert explain == "" + + # User without matching namespace + user_without_namespace = User( + username="testuser", roles=[], groups=[], namespaces=["test"] + ) + result, explain = policy.validate_user(user_without_namespace) + assert result is False + assert "permitted namespaces" in explain + + def test_namespace_based_policy_equality(self): + """Test namespace-based policy equality.""" + policy1 = NamespaceBasedPolicy(namespaces=["production", "staging"]) + policy2 = NamespaceBasedPolicy(namespaces=["staging", "production"]) + policy3 = NamespaceBasedPolicy(namespaces=["test"]) + + assert policy1 == policy2 + assert policy1 != policy3 + + +class TestCombinedGroupNamespacePolicy: + """Test CombinedGroupNamespacePolicy functionality.""" + + def test_combined_policy_validation_both_match(self): + """Test combined policy validation when both group and namespace match.""" + policy = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + + user = User( + username="testuser", + roles=[], + groups=["data-team"], + namespaces=["production"], + ) + result, explain = policy.validate_user(user) + assert result is True + assert explain == "" + + def test_combined_policy_validation_group_matches_namespace_doesnt(self): + """Test combined policy validation when group matches but namespace doesn't.""" + policy = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + + user = User( + username="testuser", roles=[], groups=["data-team"], namespaces=["staging"] + ) + result, _ = policy.validate_user(user) + assert result is True + + def test_combined_policy_validation_namespace_matches_group_doesnt(self): + """Test combined policy validation when namespace matches but group doesn't.""" + policy = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + + user = User( + username="testuser", + roles=[], + groups=["other-team"], + namespaces=["production"], + ) + result, _ = policy.validate_user(user) + assert result is True + + def test_combined_policy_validation_neither_matches(self): + """Test combined policy validation when neither group nor namespace matches.""" + policy = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + + user = User( + username="testuser", roles=[], groups=["other-team"], namespaces=["staging"] + ) + result, _ = policy.validate_user(user) + assert result is False + + def test_combined_policy_equality(self): + """Test combined policy equality.""" + policy1 = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + policy2 = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + policy3 = CombinedGroupNamespacePolicy( + groups=["other-team"], namespaces=["production"] + ) + + assert policy1 == policy2 + assert policy1 != policy3 + + def test_combined_policy_proto_serialization(self): + """Test CombinedGroupNamespacePolicy protobuf serialization and deserialization.""" + policy = CombinedGroupNamespacePolicy( + groups=["data-team"], namespaces=["production"] + ) + + # Test to_proto + proto = policy.to_proto() + assert proto.HasField("combined_group_namespace_policy") + assert list(proto.combined_group_namespace_policy.groups) == ["data-team"] + assert list(proto.combined_group_namespace_policy.namespaces) == ["production"] + + # Test from_proto + restored_policy = CombinedGroupNamespacePolicy.from_proto(proto) + assert restored_policy.groups == ["data-team"] + assert restored_policy.namespaces == ["production"] + assert policy == restored_policy + + +class TestBackwardCompatibility: + """Test backward compatibility with existing role-based policies.""" + + def test_role_based_policy_still_works(self): + """Test that existing role-based policies still work.""" + policy = RoleBasedPolicy(roles=["feast-reader"]) + + user = User( + username="testuser", + roles=["feast-reader"], + groups=["data-team"], + namespaces=["production"], + ) + result, explain = policy.validate_user(user) + assert result is True + + def test_user_with_groups_namespaces_works_with_role_policy(self): + """Test that users with groups and namespaces work with role-based policies.""" + policy = RoleBasedPolicy(roles=["feast-reader"]) + + user = User( + username="testuser", + roles=["feast-reader"], + groups=["data-team"], + namespaces=["production"], + ) + result, _ = policy.validate_user(user) + assert result is True diff --git a/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py b/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py index 0395f995410..71a883e1933 100644 --- a/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py +++ b/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py @@ -170,7 +170,8 @@ def _test_list_entities(client_fs: FeatureStore, permissions: list[Permission]): def _no_permission_retrieved(permissions: list[Permission]) -> bool: - return len(permissions) == 0 + # With security-first approach, no permissions means access should be denied + return False def _test_list_permissions( @@ -278,13 +279,17 @@ def _is_permission_enabled( permissions: list[Permission], permission: Permission, ): - return _is_auth_enabled(client_fs) and ( - _no_permission_retrieved(permissions) - or ( - _permissions_exist_in_permission_list( - [read_permissions_perm, permission], permissions - ) - ) + # With security-first approach, if no permissions are defined, access should be denied + if not _is_auth_enabled(client_fs): + return True # No auth enabled, allow access + + # If auth is enabled but no permissions are defined, deny access (security-first) + if len(permissions) == 0: + return False + + # Check if the specific permission exists + return _permissions_exist_in_permission_list( + [read_permissions_perm, permission], permissions ) diff --git a/sdk/python/tests/unit/permissions/test_decorator.py b/sdk/python/tests/unit/permissions/test_decorator.py index f434301a2ce..92db72c93d1 100644 --- a/sdk/python/tests/unit/permissions/test_decorator.py +++ b/sdk/python/tests/unit/permissions/test_decorator.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize( "username, can_read, can_write", [ - (None, True, True), + (None, False, False), ("r", True, False), ("w", False, True), ("rw", True, True), diff --git a/sdk/python/tests/unit/permissions/test_security_manager.py b/sdk/python/tests/unit/permissions/test_security_manager.py index 11b8dfb88ea..ee0ec9e079a 100644 --- a/sdk/python/tests/unit/permissions/test_security_manager.py +++ b/sdk/python/tests/unit/permissions/test_security_manager.py @@ -15,7 +15,7 @@ @pytest.mark.parametrize( "username, requested_actions, allowed, allowed_single, raise_error_in_assert, raise_error_in_permit, intra_communication_flag", [ - (None, [], True, [True, True], [False, False], False, False), + (None, [], False, [False, False], [True, True], False, False), (None, [], True, [True, True], [False, False], False, True), ( "r", @@ -219,7 +219,7 @@ def test_access_SecuredFeatureView( @pytest.mark.parametrize( "username, allowed, intra_communication_flag", [ - (None, True, False), + (None, False, False), (None, True, True), ("r", False, False), ("r", True, True), @@ -275,7 +275,7 @@ def getter(name: str, project: str, allow_cache: bool): @pytest.mark.parametrize( "username, allowed, intra_communication_flag", [ - (None, True, False), + (None, False, False), (None, True, True), ("r", False, False), ("r", True, True),