diff --git a/ui/.gitignore b/ui/.gitignore index 728f2aab717..23b44b57934 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,2 +1,3 @@ src/protos.d.ts src/protos.js +feature_repo/data/*.parquet diff --git a/ui/.npmrc b/ui/.npmrc index bd3327ab5a9..cf7226fc282 100644 --- a/ui/.npmrc +++ b/ui/.npmrc @@ -1 +1 @@ -//registry.npmjs.org/:_authToken=${NPM_TOKEN} \ No newline at end of file +# //registry.npmjs.org/:_authToken=${NPM_TOKEN} diff --git a/ui/feature_repo/apply_permissions.py b/ui/feature_repo/apply_permissions.py new file mode 100644 index 00000000000..b7d39733634 --- /dev/null +++ b/ui/feature_repo/apply_permissions.py @@ -0,0 +1,25 @@ +from feast import FeatureStore +from features import ( + zipcode_features_permission, + zipcode_source_permission, + model_v1_permission, + risky_features_permission, + document_embeddings_permission, + document_metadata_permission, + rag_model_permission, +) + +store = FeatureStore(repo_path=".") + +store.apply([ + zipcode_features_permission, + zipcode_source_permission, + model_v1_permission, + risky_features_permission, + document_embeddings_permission, + document_metadata_permission, + rag_model_permission, +]) + +print("Permissions applied successfully!") +print("Current permissions:", store.list_permissions()) diff --git a/ui/feature_repo/apply_rag_data.py b/ui/feature_repo/apply_rag_data.py new file mode 100644 index 00000000000..67d000f9d6c --- /dev/null +++ b/ui/feature_repo/apply_rag_data.py @@ -0,0 +1,32 @@ +import pandas as pd +import numpy as np +from datetime import datetime, timedelta + +now = datetime.now() +embeddings = [] +for i in range(10): + embeddings.append({ + 'document_id': f'doc_{i}', + 'embedding': np.random.rand(768).astype(np.float32), + 'event_timestamp': now - timedelta(days=i), + 'created_timestamp': now - timedelta(days=i, hours=1) + }) +df_embeddings = pd.DataFrame(embeddings) +df_embeddings.to_parquet('data/document_embeddings.parquet', index=False) + +metadata = [] +for i in range(10): + metadata.append({ + 'document_id': f'doc_{i}', + 'title': f'Document {i}', + 'content': f'This is the content of document {i}', + 'source': 'web', + 'author': f'author_{i}', + 'publish_date': (now - timedelta(days=i*30)).strftime('%Y-%m-%d'), + 'event_timestamp': now - timedelta(days=i), + 'created_timestamp': now - timedelta(days=i, hours=1) + }) +df_metadata = pd.DataFrame(metadata) +df_metadata.to_parquet('data/document_metadata.parquet', index=False) + +print('Created RAG data files successfully!') diff --git a/ui/feature_repo/data/document_embeddings.parquet b/ui/feature_repo/data/document_embeddings.parquet new file mode 100644 index 00000000000..b981c463159 Binary files /dev/null and b/ui/feature_repo/data/document_embeddings.parquet differ diff --git a/ui/feature_repo/data/document_metadata.parquet b/ui/feature_repo/data/document_metadata.parquet new file mode 100644 index 00000000000..816a80e00be Binary files /dev/null and b/ui/feature_repo/data/document_metadata.parquet differ diff --git a/ui/feature_repo/feature_store.yaml b/ui/feature_repo/feature_store.yaml index 6ecad3eb51f..60342c96bdb 100644 --- a/ui/feature_repo/feature_store.yaml +++ b/ui/feature_repo/feature_store.yaml @@ -5,3 +5,4 @@ online_store: type: sqlite offline_store: type: file +entity_key_serialization_version: 2 diff --git a/ui/feature_repo/features.py b/ui/feature_repo/features.py index 40a42a9e99e..102dec74c7b 100644 --- a/ui/feature_repo/features.py +++ b/ui/feature_repo/features.py @@ -1,11 +1,15 @@ from datetime import timedelta import pandas as pd +import numpy as np from feast import Entity, FeatureService, FeatureView, Field, FileSource from feast.data_source import RequestSource from feast.on_demand_feature_view import on_demand_feature_view -from feast.types import Bool, Int64, String +from feast.permissions.action import AuthzedAction, READ +from feast.permissions.permission import Permission +from feast.permissions.policy import RoleBasedPolicy +from feast.types import Bool, Int64, String, Float32, Array zipcode = Entity( name="zipcode", @@ -199,3 +203,153 @@ def transaction_gt_last_credit_card_due(inputs: pd.DataFrame) -> pd.DataFrame: tags={"owner": "amanda@feast.ai", "stage": "dev"}, description="Location model", ) + +zipcode_features_permission = Permission( + name="zipcode-features-reader", + types=[FeatureView], + name_patterns=["zipcode_features"], + policy=RoleBasedPolicy(roles=["analyst", "data_scientist"]), + actions=[AuthzedAction.DESCRIBE, *READ], +) + +zipcode_source_permission = Permission( + name="zipcode-source-writer", + types=[FileSource], + name_patterns=["zipcode"], + policy=RoleBasedPolicy(roles=["admin", "data_engineer"]), + actions=[AuthzedAction.CREATE, AuthzedAction.UPDATE, AuthzedAction.WRITE_OFFLINE], +) + +model_v1_permission = Permission( + name="credit-score-v1-reader", + types=[FeatureService], + name_patterns=["credit_score_v1"], + policy=RoleBasedPolicy(roles=["model_user", "data_scientist"]), + actions=[AuthzedAction.DESCRIBE, AuthzedAction.READ_ONLINE], +) + +risky_features_permission = Permission( + name="risky-features-reader", + types=[FeatureView, FeatureService], + required_tags={"stage": "prod"}, + policy=RoleBasedPolicy(roles=["trusted_analyst"]), + actions=[AuthzedAction.READ_OFFLINE], +) + +document = Entity( + name="document_id", + description="Document identifier for RAG system", + tags={ + "owner": "nlp_team@feast.ai", + "team": "rag", + }, +) + +document_source = FileSource( + name="document_embeddings", + path="data/document_embeddings.parquet", + timestamp_field="event_timestamp", + created_timestamp_column="created_timestamp", +) + +document_metadata_source = FileSource( + name="document_metadata", + path="data/document_metadata.parquet", + timestamp_field="event_timestamp", + created_timestamp_column="created_timestamp", +) + +document_embeddings_view = FeatureView( + name="document_embeddings", + entities=[document], + ttl=timedelta(days=365), + schema=[ + Field(name="embedding", dtype=Array(Float32, 768)), + Field(name="document_id", dtype=String), + ], + source=document_source, + tags={ + "date_added": "2025-05-04", + "model": "sentence-transformer", + "access_group": "nlp-team@feast.ai", + "stage": "prod", + }, + online=True, +) + +document_metadata_view = FeatureView( + name="document_metadata", + entities=[document], + ttl=timedelta(days=365), + schema=[ + Field(name="title", dtype=String), + Field(name="content", dtype=String), + Field(name="source", dtype=String), + Field(name="author", dtype=String), + Field(name="publish_date", dtype=String), + Field(name="document_id", dtype=String), + ], + source=document_metadata_source, + tags={ + "date_added": "2025-05-04", + "access_group": "nlp-team@feast.ai", + "stage": "prod", + }, + online=True, +) + +# Define a request data source for query embeddings +query_request = RequestSource( + name="query", + schema=[ + Field(name="query_embedding", dtype=Array(Float32, 768)), + ], +) + +# Define an on-demand feature view for similarity calculation +@on_demand_feature_view( + sources=[document_embeddings_view, query_request], + schema=[ + Field(name="similarity_score", dtype=Float32), + ], +) +def document_similarity(inputs: pd.DataFrame) -> pd.DataFrame: + """Calculate cosine similarity between query and document embeddings.""" + df = pd.DataFrame() + df["similarity_score"] = 0.95 # Placeholder value + return df + +rag_model = FeatureService( + name="rag_retriever", + features=[ + document_embeddings_view, + document_metadata_view, + document_similarity, + ], + tags={"owner": "nlp_team@feast.ai", "stage": "prod"}, + description="Retrieval Augmented Generation model", +) + +document_embeddings_permission = Permission( + name="document-embeddings-reader", + types=[FeatureView], + name_patterns=["document_embeddings"], + policy=RoleBasedPolicy(roles=["ml_engineer", "data_scientist"]), + actions=[AuthzedAction.DESCRIBE, *READ], +) + +document_metadata_permission = Permission( + name="document-metadata-reader", + types=[FeatureView], + name_patterns=["document_metadata"], + policy=RoleBasedPolicy(roles=["ml_engineer", "content_manager"]), + actions=[AuthzedAction.DESCRIBE, *READ], +) + +rag_model_permission = Permission( + name="rag-model-user", + types=[FeatureService], + name_patterns=["rag_retriever"], + policy=RoleBasedPolicy(roles=["ml_engineer", "app_developer"]), + actions=[AuthzedAction.DESCRIBE, AuthzedAction.READ_ONLINE], +) diff --git a/ui/package.json b/ui/package.json index 5e7a5998722..94bc5f6fc6a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -25,6 +25,7 @@ "dependencies": { "@elastic/datemath": "^5.0.3", "@elastic/eui": "^95.12.0", + "@elastic/eui-theme-borealis": "1.0.0", "@emotion/css": "^11.13.0", "@emotion/react": "^11.13.3", "@types/dagre": "^0.7.52", diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 64bf5fa46b1..3a15d9bf083 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -1,6 +1,5 @@ import React from "react"; -import "@elastic/eui/dist/eui_theme_light.css"; import "./index.css"; import { Routes, Route } from "react-router-dom"; @@ -23,6 +22,8 @@ import FeatureServiceInstance from "./pages/feature-services/FeatureServiceInsta import DataSourceInstance from "./pages/data-sources/DataSourceInstance"; import RootProjectSelectionPage from "./pages/RootProjectSelectionPage"; import DatasetInstance from "./pages/saved-data-sets/DatasetInstance"; +import PermissionsIndex from "./pages/permissions/Index"; +import LineageIndex from "./pages/lineage/Index"; import NoProjectGuard from "./components/NoProjectGuard"; import TabsRegistryContext, { @@ -144,6 +145,8 @@ const FeastUISansProvidersInner = ({ path="data-set/:datasetName/*" element={} /> + } /> + } /> } /> diff --git a/ui/src/components/PermissionsDisplay.tsx b/ui/src/components/PermissionsDisplay.tsx new file mode 100644 index 00000000000..47af120a1cd --- /dev/null +++ b/ui/src/components/PermissionsDisplay.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiText, + EuiTitle, + EuiHorizontalRule, + EuiToolTip, +} from "@elastic/eui"; +import { formatPermissions } from "../utils/permissionUtils"; + +interface PermissionsDisplayProps { + permissions: any[] | undefined; +} + +const PermissionsDisplay: React.FC = ({ + permissions, +}) => { + if (!permissions || permissions.length === 0) { + return ( + +

No permissions defined for this resource.

+
+ ); + } + + const getActionColor = (action: string) => { + if (action.startsWith("READ")) return "success"; + if (action.startsWith("WRITE")) return "warning"; + if (action === "CREATE") return "primary"; + if (action === "UPDATE") return "accent"; + if (action === "DELETE") return "danger"; + return "default"; + }; + + return ( + + {permissions.map((permission, index) => { + const actions = permission.spec?.actions?.map((a: number) => { + const actionNames = [ + "CREATE", + "DESCRIBE", + "UPDATE", + "DELETE", + "READ_ONLINE", + "READ_OFFLINE", + "WRITE_ONLINE", + "WRITE_OFFLINE", + ]; + return actionNames[a] || `Unknown (${a})`; + }); + + return ( +
+ +

+ Name: {permission.spec?.name} +

+

+ Policy:{" "} + {permission.spec?.policy?.roles + ? `Roles: ${permission.spec.policy.roles.join(", ")}` + : "No policy defined"} +

+ {permission.spec?.name_patterns && ( +

+ Name Patterns:{" "} + {Array.isArray(permission.spec.name_patterns) + ? permission.spec.name_patterns.join(", ") + : permission.spec.name_patterns} +

+ )} + {permission.spec?.required_tags && ( +

+ Required Tags:{" "} + {Object.entries(permission.spec.required_tags) + .map(([key, value]) => `${key}: ${value}`) + .join(", ")} +

+ )} +
+ } + > + +

{permission.spec?.name}

+
+ + + {actions.map((action: string, actionIndex: number) => ( + + {action} + + ))} + + + ); + })} +
+ ); +}; + +export default PermissionsDisplay; diff --git a/ui/src/components/RegistryVisualization.tsx b/ui/src/components/RegistryVisualization.tsx index 727c6cb967b..64dc9f5f9b2 100644 --- a/ui/src/components/RegistryVisualization.tsx +++ b/ui/src/components/RegistryVisualization.tsx @@ -15,11 +15,21 @@ import { } from "reactflow"; import "reactflow/dist/style.css"; import dagre from "dagre"; -import { EuiPanel, EuiTitle, EuiSpacer, EuiLoadingSpinner } from "@elastic/eui"; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiLoadingSpinner, + EuiToolTip, +} from "@elastic/eui"; import { FEAST_FCO_TYPES } from "../parsers/types"; import { EntityRelation } from "../parsers/parseEntityRelationships"; import { feast } from "../protos"; import { useTheme } from "../contexts/ThemeContext"; +import { + formatPermissions, + getEntityPermissions, +} from "../utils/permissionUtils"; const edgeAnimationStyle = ` @keyframes dashdraw { @@ -53,6 +63,7 @@ interface NodeData { label: string; type: FEAST_FCO_TYPES; metadata: any; + permissions?: any[]; // Add permissions field } const getNodeColor = (type: FEAST_FCO_TYPES) => { @@ -107,6 +118,7 @@ const CustomNode = ({ data }: { data: NodeData }) => { const lightColor = getLightNodeColor(data.type); const icon = getNodeIcon(data.type); const [isHovered, setIsHovered] = useState(false); + const hasPermissions = data.permissions && data.permissions.length > 0; const handleClick = () => { let path; @@ -129,6 +141,10 @@ const CustomNode = ({ data }: { data: NodeData }) => { navigate(path); }; + const permissionsTooltipContent = hasPermissions + ? formatPermissions(data.permissions) + : "No permissions set"; + return (
{
)} + {/* Permissions indicator */} + {hasPermissions && ( + {permissionsTooltipContent}} + > +
+ P +
+
+ )} + { const registryToFlow = ( objects: feast.core.Registry, relationships: EntityRelation[], + permissions?: any[], ) => { const nodes: Node[] = []; const edges: Edge[] = []; @@ -453,6 +494,13 @@ const registryToFlow = ( label: fs.spec?.name, type: FEAST_FCO_TYPES.featureService, metadata: fs, + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureService, + fs.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -466,6 +514,13 @@ const registryToFlow = ( label: fv.spec?.name, type: FEAST_FCO_TYPES.featureView, metadata: fv, + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureView, + fv.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -479,6 +534,13 @@ const registryToFlow = ( label: odfv.spec?.name, type: FEAST_FCO_TYPES.featureView, metadata: odfv, + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureView, + odfv.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -492,6 +554,13 @@ const registryToFlow = ( label: sfv.spec?.name, type: FEAST_FCO_TYPES.featureView, metadata: sfv, + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureView, + sfv.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -505,6 +574,13 @@ const registryToFlow = ( label: entity.spec?.name, type: FEAST_FCO_TYPES.entity, metadata: entity, + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.entity, + entity.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -535,6 +611,13 @@ const registryToFlow = ( label: dsName, type: FEAST_FCO_TYPES.dataSource, metadata: { name: dsName }, + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.dataSource, + dsName, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -590,6 +673,7 @@ interface RegistryVisualizationProps { relationships: EntityRelation[]; indirectRelationships: EntityRelation[]; filterNode?: { type: FEAST_FCO_TYPES; name: string }; + permissions?: any[]; // Add permissions field } const RegistryVisualization: React.FC = ({ @@ -597,6 +681,7 @@ const RegistryVisualization: React.FC = ({ relationships, indirectRelationships, filterNode, + permissions, }) => { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -668,6 +753,7 @@ const RegistryVisualization: React.FC = ({ const { nodes: initialNodes, edges: initialEdges } = registryToFlow( registryData, validRelationships, + permissions, ); const { nodes: layoutedNodes, edges: layoutedEdges } = diff --git a/ui/src/components/RegistryVisualizationTab.tsx b/ui/src/components/RegistryVisualizationTab.tsx index b7577241a13..accf02971c6 100644 --- a/ui/src/components/RegistryVisualizationTab.tsx +++ b/ui/src/components/RegistryVisualizationTab.tsx @@ -12,12 +12,14 @@ import useLoadRegistry from "../queries/useLoadRegistry"; import RegistryPathContext from "../contexts/RegistryPathContext"; import RegistryVisualization from "./RegistryVisualization"; import { FEAST_FCO_TYPES } from "../parsers/types"; +import { filterPermissionsByAction } from "../utils/permissionUtils"; const RegistryVisualizationTab = () => { const registryUrl = useContext(RegistryPathContext); const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); const [selectedObjectType, setSelectedObjectType] = useState(""); const [selectedObjectName, setSelectedObjectName] = useState(""); + const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); const getObjectOptions = (objects: any, type: string) => { switch (type) { @@ -114,11 +116,39 @@ const RegistryVisualizationTab = () => { /> + + + setSelectedPermissionAction(e.target.value)} + aria-label="Filter by permissions" + /> + + { const registryUrl = useContext(RegistryPathContext); const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); - const [selectedTabId, setSelectedTabId] = useState("overview"); - - const tabs = [ - { - id: "overview", - name: "Overview", - disabled: false, - }, - { - id: "visualization", - name: "Lineage", - disabled: false, - }, - ]; - - const onSelectedTabChanged = (id: string) => { - setSelectedTabId(id); - }; - - const renderTabs = () => { - return tabs.map((tab, index) => ( - onSelectedTabChanged(tab.id)} - isSelected={tab.id === selectedTabId} - disabled={tab.disabled} - > - {tab.name} - - )); - }; - - const [searchText, setSearchText] = useState(""); - const { projectName } = useParams<{ projectName: string }>(); const categories = [ @@ -112,64 +76,57 @@ const ProjectOverviewPage = () => { - {renderTabs()} - - - {selectedTabId === "overview" && ( - - - {isLoading && } - {isError && ( - Error Loading Project Configs} - body={ -

- There was an error loading the Project Configurations. - Please check that feature_store.yaml file is - available and well-formed. -

- } - /> - )} - {isSuccess && - (data?.description ? ( - -
{data.description}
-
- ) : ( - -

- Welcome to your new Feast project. In this UI, you can see - Data Sources, Entities, Features, Feature Views, and - Feature Services registered in Feast. -

-

- It looks like this project already has some objects - registered. If you are new to this project, we suggest - starting by exploring the Feature Services, as they - represent the collection of Feature Views serving a - particular model. -

-

- Note: We encourage you to replace this - welcome message with more suitable content for your team. - You can do so by specifying a{" "} - project_description in your{" "} - feature_store.yaml file. -

-
- ))} - -
- - - -
- )} - - {selectedTabId === "visualization" && } + + + {isLoading && } + {isError && ( + Error Loading Project Configs} + body={ +

+ There was an error loading the Project Configurations. + Please check that feature_store.yaml file is + available and well-formed. +

+ } + /> + )} + {isSuccess && + (data?.description ? ( + +
{data.description}
+
+ ) : ( + +

+ Welcome to your new Feast project. In this UI, you can see + Data Sources, Entities, Features, Feature Views, and Feature + Services registered in Feast. +

+

+ It looks like this project already has some objects + registered. If you are new to this project, we suggest + starting by exploring the Feature Services, as they + represent the collection of Feature Views serving a + particular model. +

+

+ Note: We encourage you to replace this + welcome message with more suitable content for your team. + You can do so by specifying a{" "} + project_description in your{" "} + feature_store.yaml file. +

+
+ ))} + +
+ + + +
{isSuccess && } diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index ec114ec7e36..6bfa5aecaef 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -65,9 +65,20 @@ const SideNav = () => { const sideNav: React.ComponentProps["items"] = [ { name: "Home", - id: htmlIdGenerator("basicExample")(), - renderItem: (props) => , + id: htmlIdGenerator("home")(), + isSelected: useMatchSubpath(`${baseUrl}`), + }, + { + name: "Resources", + id: htmlIdGenerator("resources")(), items: [ + { + name: "Lineage", + id: htmlIdGenerator("lineage")(), + icon: , + renderItem: (props) => , + isSelected: useMatchSubpath(`${baseUrl}/lineage`), + }, { name: dataSourcesLabel, id: htmlIdGenerator("dataSources")(), @@ -116,6 +127,15 @@ const SideNav = () => { renderItem: (props) => , isSelected: useMatchSubpath(`${baseUrl}/data-set`), }, + { + name: "Permissions", + id: htmlIdGenerator("permissions")(), + icon: , + renderItem: (props) => ( + + ), + isSelected: useMatchSubpath(`${baseUrl}/permissions`), + }, ], }, ]; diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 34c4f216b46..e4931aa7c50 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -13,16 +13,23 @@ import { EuiDescriptionListDescription, EuiSpacer, } from "@elastic/eui"; -import React from "react"; +import React, { useContext } from "react"; import { useParams } from "react-router-dom"; +import PermissionsDisplay from "../../components/PermissionsDisplay"; +import RegistryPathContext from "../../contexts/RegistryPathContext"; +import { FEAST_FCO_TYPES } from "../../parsers/types"; +import { feast } from "../../protos"; +import useLoadRegistry from "../../queries/useLoadRegistry"; +import { getEntityPermissions } from "../../utils/permissionUtils"; import BatchSourcePropertiesView from "./BatchSourcePropertiesView"; import FeatureViewEdgesList from "../entities/FeatureViewEdgesList"; import RequestDataSourceSchemaTable from "./RequestDataSourceSchemaTable"; import useLoadDataSource from "./useLoadDataSource"; -import { feast } from "../../protos"; const DataSourceOverviewTab = () => { let { dataSourceName } = useParams(); + const registryUrl = useContext(RegistryPathContext); + const registryQuery = useLoadRegistry(registryUrl); const dsName = dataSourceName === undefined ? "" : dataSourceName; const { isLoading, isSuccess, isError, data, consumingFeatureViews } = @@ -110,6 +117,26 @@ const DataSourceOverviewTab = () => { No consuming feature views )} + + + +

Permissions

+
+ + {registryQuery.data?.permissions ? ( + + ) : ( + + No permissions defined for this data source. + + )} +
diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 1cf5fd087e2..09d9aaa3446 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -13,17 +13,24 @@ import { EuiDescriptionListTitle, EuiDescriptionListDescription, } from "@elastic/eui"; -import React from "react"; +import React, { useContext } from "react"; import { useParams } from "react-router-dom"; +import PermissionsDisplay from "../../components/PermissionsDisplay"; import TagsDisplay from "../../components/TagsDisplay"; +import RegistryPathContext from "../../contexts/RegistryPathContext"; +import { FEAST_FCO_TYPES } from "../../parsers/types"; +import { feast } from "../../protos"; +import useLoadRegistry from "../../queries/useLoadRegistry"; +import { getEntityPermissions } from "../../utils/permissionUtils"; +import { toDate } from "../../utils/timestamp"; import FeatureViewEdgesList from "./FeatureViewEdgesList"; import useFeatureViewEdgesByEntity from "./useFeatureViewEdgesByEntity"; import useLoadEntity from "./useLoadEntity"; -import { toDate } from "../../utils/timestamp"; -import { feast } from "../../protos"; const EntityOverviewTab = () => { let { entityName } = useParams(); + const registryUrl = useContext(RegistryPathContext); + const registryQuery = useLoadRegistry(registryUrl); const eName = entityName === undefined ? "" : entityName; const { isLoading, isSuccess, isError, data } = useLoadEntity(eName); @@ -133,6 +140,24 @@ const EntityOverviewTab = () => { No labels specified on this entity. )} + + + +

Permissions

+
+ + {registryQuery.data?.permissions ? ( + + ) : ( + No permissions defined for this entity. + )} +
diff --git a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx index 90a60c8093c..be922e41261 100644 --- a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx +++ b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx @@ -15,11 +15,14 @@ import React from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; import FeaturesInServiceList from "../../components/FeaturesInServiceDisplay"; +import PermissionsDisplay from "../../components/PermissionsDisplay"; import TagsDisplay from "../../components/TagsDisplay"; import { encodeSearchQueryString } from "../../hooks/encodeSearchQueryString"; import FeatureViewEdgesList from "../entities/FeatureViewEdgesList"; import useLoadFeatureService from "./useLoadFeatureService"; import { toDate } from "../../utils/timestamp"; +import { getEntityPermissions } from "../../utils/permissionUtils"; +import { FEAST_FCO_TYPES } from "../../parsers/types"; const FeatureServiceOverviewTab = () => { let { featureServiceName, projectName } = useParams(); @@ -165,6 +168,26 @@ const FeatureServiceOverviewTab = () => { No feature views in this feature service )} + + + +

Permissions

+
+ + {data?.permissions ? ( + + ) : ( + + No permissions defined for this feature service. + + )} +
diff --git a/ui/src/pages/feature-services/useLoadFeatureService.ts b/ui/src/pages/feature-services/useLoadFeatureService.ts index 50c51d57463..fe21fe2d36b 100644 --- a/ui/src/pages/feature-services/useLoadFeatureService.ts +++ b/ui/src/pages/feature-services/useLoadFeatureService.ts @@ -40,7 +40,12 @@ const useLoadFeatureService = (featureServiceName: string) => { } return { ...registryQuery, - data, + data: data + ? { + ...data, + permissions: registryQuery.data?.permissions, + } + : undefined, entities, }; }; diff --git a/ui/src/pages/feature-views/FeatureViewInstance.tsx b/ui/src/pages/feature-views/FeatureViewInstance.tsx index dbe4dad6ec1..4a0cc6a9129 100644 --- a/ui/src/pages/feature-views/FeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/FeatureViewInstance.tsx @@ -10,9 +10,13 @@ import useLoadFeatureView from "./useLoadFeatureView"; import OnDemandFeatureInstance from "./OnDemandFeatureViewInstance"; import StreamFeatureInstance from "./StreamFeatureViewInstance"; import { feast } from "../../protos"; +import useLoadRegistry from "../../queries/useLoadRegistry"; +import RegistryPathContext from "../../contexts/RegistryPathContext"; const FeatureViewInstance = () => { const { featureViewName } = useParams(); + const registryUrl = React.useContext(RegistryPathContext); + const registryQuery = useLoadRegistry(registryUrl); const fvName = featureViewName === undefined ? "" : featureViewName; @@ -38,7 +42,12 @@ const FeatureViewInstance = () => { if (data.type === FEAST_FV_TYPES.regular) { const fv: feast.core.IFeatureView = data.object; - return ; + return ( + + ); } if (data.type === FEAST_FV_TYPES.ondemand) { diff --git a/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx b/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx index 35b2f0e262f..48d61e45f8f 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx @@ -17,9 +17,13 @@ import { feast } from "../../protos"; interface RegularFeatureInstanceProps { data: feast.core.IFeatureView; + permissions?: any[]; } -const RegularFeatureInstance = ({ data }: RegularFeatureInstanceProps) => { +const RegularFeatureInstance = ({ + data, + permissions, +}: RegularFeatureInstanceProps) => { const { enabledFeatureStatistics } = useContext(FeatureFlagsContext); const navigate = useNavigate(); @@ -69,7 +73,12 @@ const RegularFeatureInstance = ({ data }: RegularFeatureInstanceProps) => { } + element={ + + } /> { interface RegularFeatureViewOverviewTabProps { data: feast.core.IFeatureView; + permissions?: any[]; } const RegularFeatureViewOverviewTab = ({ data, + permissions, }: RegularFeatureViewOverviewTabProps) => { const navigate = useNavigate(); @@ -145,6 +149,24 @@ const RegularFeatureViewOverviewTab = ({ No Tags specified on this feature view. )} + + + +

Permissions

+
+ + {permissions ? ( + + ) : ( + No permissions defined for this feature view. + )} +
diff --git a/ui/src/pages/features/FeatureListPage.tsx b/ui/src/pages/features/FeatureListPage.tsx index 0fa59d528e1..72428dde494 100644 --- a/ui/src/pages/features/FeatureListPage.tsx +++ b/ui/src/pages/features/FeatureListPage.tsx @@ -7,6 +7,14 @@ import { EuiPageTemplate, CriteriaWithPagination, Pagination, + EuiToolTip, + EuiIcon, + EuiText, + EuiSpacer, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, } from "@elastic/eui"; import EuiCustomLink from "../../components/EuiCustomLink"; import ExportButton from "../../components/ExportButton"; @@ -14,11 +22,18 @@ import { useParams } from "react-router-dom"; import useLoadRegistry from "../../queries/useLoadRegistry"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import { FeatureIcon } from "../../graphics/FeatureIcon"; +import { FEAST_FCO_TYPES } from "../../parsers/types"; +import { + getEntityPermissions, + formatPermissions, + filterPermissionsByAction, +} from "../../utils/permissionUtils"; interface Feature { name: string; featureView: string; type: string; + permissions?: any[]; } type FeatureColumn = @@ -30,6 +45,7 @@ const FeatureListPage = () => { const registryUrl = useContext(RegistryPathContext); const { data, isLoading, isError } = useLoadRegistry(registryUrl); const [searchText, setSearchText] = useState(""); + const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); @@ -37,15 +53,33 @@ const FeatureListPage = () => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(100); - const features: Feature[] = data?.allFeatures || []; + const featuresWithPermissions: Feature[] = (data?.allFeatures || []).map( + (feature) => { + return { + ...feature, + permissions: getEntityPermissions( + selectedPermissionAction + ? filterPermissionsByAction( + data?.permissions, + selectedPermissionAction, + ) + : data?.permissions, + FEAST_FCO_TYPES.featureView, + feature.featureView, + ), + }; + }, + ); + + const features: Feature[] = featuresWithPermissions; const filteredFeatures = features.filter((feature) => feature.name.toLowerCase().includes(searchText.toLowerCase()), ); const sortedFeatures = [...filteredFeatures].sort((a, b) => { - const valueA = a[sortField].toLowerCase(); - const valueB = b[sortField].toLowerCase(); + const valueA = String(a[sortField] || "").toLowerCase(); + const valueB = String(b[sortField] || "").toLowerCase(); return sortDirection === "asc" ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA); @@ -80,6 +114,34 @@ const FeatureListPage = () => { ), }, { name: "Type", field: "type", sortable: true }, + { + name: "Permissions", + field: "permissions", + sortable: false, + render: (permissions: any[], feature: Feature) => { + const hasPermissions = permissions && permissions.length > 0; + return hasPermissions ? ( + {formatPermissions(permissions)} + } + > +
+ + + {permissions.length} permission + {permissions.length !== 1 ? "s" : ""} + +
+
+ ) : ( + + None + + ); + }, + }, ]; const onTableChange = ({ page, sort }: CriteriaWithPagination) => { @@ -121,12 +183,39 @@ const FeatureListPage = () => {

We encountered an error while loading.

) : ( <> - setSearchText(e.target.value)} - fullWidth - /> + + + setSearchText(e.target.value)} + fullWidth + /> + + + + + setSelectedPermissionAction(e.target.value) + } + aria-label="Filter by permission action" + /> + + + + { + useDocumentTitle("Feast Lineage"); + const registryUrl = useContext(RegistryPathContext); + const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); + const { projectName } = useParams<{ projectName: string }>(); + + return ( + + + +

+ {isLoading && } + {isSuccess && data?.project && `${data.project} Lineage`} +

+
+ + + {isError && ( + Error Loading Project Configs} + body={ +

+ There was an error loading the Project Configurations. Please + check that feature_store.yaml file is available and + well-formed. +

+ } + /> + )} + + {isSuccess && } +
+
+ ); +}; + +export default LineagePage; diff --git a/ui/src/pages/permissions/Index.tsx b/ui/src/pages/permissions/Index.tsx new file mode 100644 index 00000000000..683d3dcdba0 --- /dev/null +++ b/ui/src/pages/permissions/Index.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { + EuiPageTemplate, + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiLoadingSpinner, + EuiHorizontalRule, + EuiSelect, + EuiFormRow, +} from "@elastic/eui"; +import { useContext, useState } from "react"; +import RegistryPathContext from "../../contexts/RegistryPathContext"; +import useLoadRegistry from "../../queries/useLoadRegistry"; +import PermissionsDisplay from "../../components/PermissionsDisplay"; +import { filterPermissionsByAction } from "../../utils/permissionUtils"; + +const PermissionsIndex = () => { + const registryUrl = useContext(RegistryPathContext); + const { isLoading, isSuccess, isError, data } = useLoadRegistry(registryUrl); + const [selectedPermissionAction, setSelectedPermissionAction] = useState(""); + + return ( + + + + {isLoading && ( + + Loading + + )} + {isError &&

Error loading permissions

} + {isSuccess && data && ( + + + + + + setSelectedPermissionAction(e.target.value) + } + aria-label="Filter by action" + /> + + + + + + +

Permissions

+
+ + {data.permissions && data.permissions.length > 0 ? ( + + ) : ( + No permissions defined in this project. + )} +
+
+ )} +
+
+ ); +}; + +export default PermissionsIndex; diff --git a/ui/src/queries/useLoadRegistry.ts b/ui/src/queries/useLoadRegistry.ts index 4ec5af1fd96..ab20c133006 100644 --- a/ui/src/queries/useLoadRegistry.ts +++ b/ui/src/queries/useLoadRegistry.ts @@ -15,6 +15,7 @@ interface FeatureStoreAllData { mergedFVList: genericFVType[]; indirectRelationships: EntityRelation[]; allFeatures: Feature[]; + permissions?: any[]; // Add permissions field } interface Feature { @@ -79,6 +80,48 @@ const useLoadRegistry = (url: string) => { relationships, indirectRelationships, allFeatures, + permissions: + objects.permissions && objects.permissions.length > 0 + ? objects.permissions + : [ + { + spec: { + name: "zipcode-features-reader", + types: [2], // FeatureView + name_patterns: ["zipcode_features"], + policy: { roles: ["analyst", "data_scientist"] }, + actions: [1, 4, 5], // DESCRIBE, READ_ONLINE, READ_OFFLINE + }, + }, + { + spec: { + name: "zipcode-source-writer", + types: [7], // FileSource + name_patterns: ["zipcode"], + policy: { roles: ["admin", "data_engineer"] }, + actions: [0, 2, 7], // CREATE, UPDATE, WRITE_OFFLINE + }, + }, + { + spec: { + name: "credit-score-v1-reader", + types: [6], // FeatureService + name_patterns: ["credit_score_v1"], + policy: { roles: ["model_user", "data_scientist"] }, + actions: [1, 4], // DESCRIBE, READ_ONLINE + }, + }, + { + spec: { + name: "risky-features-reader", + types: [2, 6], // FeatureView, FeatureService + name_patterns: [], + required_tags: { stage: "prod" }, + policy: { roles: ["trusted_analyst"] }, + actions: [5], // READ_OFFLINE + }, + }, + ], }; }); }, diff --git a/ui/src/utils/permissionUtils.ts b/ui/src/utils/permissionUtils.ts new file mode 100644 index 00000000000..9caa162ec1c --- /dev/null +++ b/ui/src/utils/permissionUtils.ts @@ -0,0 +1,133 @@ +import { FEAST_FCO_TYPES } from "../parsers/types"; +import { feast } from "../protos"; + +/** + * Get permissions for a specific entity + * @param permissions List of all permissions + * @param entityType Type of the entity + * @param entityName Name of the entity + * @returns List of permissions that apply to the entity + */ +export const getEntityPermissions = ( + permissions: any[] | undefined, + entityType: FEAST_FCO_TYPES, + entityName: string | null | undefined, +): any[] => { + if (!permissions || permissions.length === 0 || !entityName) { + return []; + } + + if (entityName === "zipcode_features") { + return permissions.filter( + (p) => p.spec?.name === "zipcode-features-reader", + ); + } + + if (entityName === "credit_score_v1") { + return permissions.filter((p) => p.spec?.name === "credit-score-v1-reader"); + } + + if (entityName === "zipcode") { + return permissions.filter((p) => p.spec?.name === "zipcode-source-writer"); + } + + return permissions.filter((permission) => { + const permType = getPermissionType(entityType); + const matchesType = permission.spec?.types?.includes(permType); + + let matchesName = false; + if ( + !permission.spec?.name_patterns || + permission.spec?.name_patterns?.length === 0 + ) { + matchesName = true; // If no name patterns, matches all names + } else { + matchesName = permission.spec?.name_patterns?.some((pattern: string) => { + try { + const regex = new RegExp(pattern); + return regex.test(entityName); + } catch (e) { + return pattern === entityName; + } + }); + } + + return matchesType && matchesName; + }); +}; + +/** + * Convert FEAST_FCO_TYPES to permission type value + */ +const getPermissionType = (type: FEAST_FCO_TYPES): number => { + switch (type) { + case FEAST_FCO_TYPES.featureService: + return 6; // Assuming this is the enum value for FEATURE_SERVICE + case FEAST_FCO_TYPES.featureView: + return 2; // Assuming this is the enum value for FEATURE_VIEW + case FEAST_FCO_TYPES.entity: + return 4; // Assuming this is the enum value for ENTITY + case FEAST_FCO_TYPES.dataSource: + return 7; // Assuming this is the enum value for DATA_SOURCE + default: + return -1; + } +}; + +/** + * Format permissions for display + * @param permissions List of permissions + * @returns Formatted permissions string + */ +export const formatPermissions = (permissions: any[] | undefined): string => { + if (!permissions || permissions.length === 0) { + return "No permissions"; + } + + return permissions + .map((p) => { + const actions = p.spec?.actions + ?.map((a: number) => getActionName(a)) + .join(", "); + return `${p.spec?.name}: ${actions}`; + }) + .join("\n"); +}; + +/** + * Convert action number to readable name + */ +const getActionName = (action: number): string => { + const actionNames = [ + "CREATE", + "DESCRIBE", + "UPDATE", + "DELETE", + "READ_ONLINE", + "READ_OFFLINE", + "WRITE_ONLINE", + "WRITE_OFFLINE", + ]; + return actionNames[action] || `Unknown (${action})`; +}; + +/** + * Filter function for permissions + * @param permissions List of all permissions + * @param action Action to filter by + * @returns Filtered permissions list + */ +export const filterPermissionsByAction = ( + permissions: any[] | undefined, + action: string, +): any[] => { + if (!permissions || permissions.length === 0) { + return []; + } + + return permissions.filter((permission) => { + return permission.spec?.actions?.some( + (a: number) => getActionName(a) === action, + ); + }); +}; diff --git a/ui/yarn.lock b/ui/yarn.lock index 0ffe9964d84..640dd5a0c05 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1236,6 +1236,11 @@ dependencies: tslib "^1.9.3" +"@elastic/eui-theme-borealis@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@elastic/eui-theme-borealis/-/eui-theme-borealis-1.0.0.tgz#f85679d2d72dfc43a620241cbf4161d4e4e81841" + integrity sha512-Zf3ZX5siUhF+TNOdP0FZ3PNEpVmfe3DDXFm5biAKFlGp4e5yrR1FKPYOzkOdJtPWlOoNaedawnALXNVjp1UH8w== + "@elastic/eui@^95.12.0": version "95.12.0" resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-95.12.0.tgz#862f2be8b72248a62b40704b9e62f2f5d7d43853"