From 85981225adb3bb796f19064e8ccf8ca62ea90eaf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 May 2025 23:46:45 +0000 Subject: [PATCH 01/30] Feature: Add permissions display to Feast UI lineage visualization Co-Authored-By: Francisco Javier Arceo --- ui/src/components/RegistryVisualization.tsx | 67 ++++++++++- .../components/RegistryVisualizationTab.tsx | 27 +++++ ui/src/queries/useLoadRegistry.ts | 2 + ui/src/utils/permissionUtils.ts | 110 ++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 ui/src/utils/permissionUtils.ts diff --git a/ui/src/components/RegistryVisualization.tsx b/ui/src/components/RegistryVisualization.tsx index 727c6cb967b..ee2f3455739 100644 --- a/ui/src/components/RegistryVisualization.tsx +++ b/ui/src/components/RegistryVisualization.tsx @@ -15,11 +15,12 @@ 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 +54,7 @@ interface NodeData { label: string; type: FEAST_FCO_TYPES; metadata: any; + permissions?: any[]; // Add permissions field } const getNodeColor = (type: FEAST_FCO_TYPES) => { @@ -107,6 +109,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 +132,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 +485,11 @@ 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 +503,11 @@ 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 +521,11 @@ 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 +539,11 @@ 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 +557,11 @@ 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 +592,11 @@ 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 +652,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 +660,7 @@ const RegistryVisualization: React.FC = ({ relationships, indirectRelationships, filterNode, + permissions, }) => { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -668,6 +732,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..9532177b717 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,36 @@ const RegistryVisualizationTab = () => { /> + + + setSelectedPermissionAction(e.target.value)} + aria-label="Filter by permissions" + /> + + { relationships, indirectRelationships, allFeatures, + permissions: objects.permissions || [], // Add permissions to the returned data }; }); }, diff --git a/ui/src/utils/permissionUtils.ts b/ui/src/utils/permissionUtils.ts new file mode 100644 index 00000000000..4fa4f343543 --- /dev/null +++ b/ui/src/utils/permissionUtils.ts @@ -0,0 +1,110 @@ +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 +): any[] => { + if (!permissions || permissions.length === 0) { + return []; + } + + return permissions.filter((permission) => { + const matchesType = permission.spec?.types?.includes( + getPermissionType(entityType) + ); + + const matchesName = + permission.spec?.name_patterns?.length === 0 || + permission.spec?.name_patterns?.some((pattern: string) => { + const regex = new RegExp(pattern); + return regex.test(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 + ); + }); +}; From 8d947dea28e051d6f29046bc579e333903c7f5a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:21:07 +0000 Subject: [PATCH 02/30] Format code with Prettier Co-Authored-By: Francisco Javier Arceo --- ui/src/components/RegistryVisualization.tsx | 93 ++++++++++++------- .../components/RegistryVisualizationTab.tsx | 5 +- ui/src/utils/permissionUtils.ts | 8 +- 3 files changed, 65 insertions(+), 41 deletions(-) diff --git a/ui/src/components/RegistryVisualization.tsx b/ui/src/components/RegistryVisualization.tsx index ee2f3455739..64dc9f5f9b2 100644 --- a/ui/src/components/RegistryVisualization.tsx +++ b/ui/src/components/RegistryVisualization.tsx @@ -15,12 +15,21 @@ import { } from "reactflow"; import "reactflow/dist/style.css"; import dagre from "dagre"; -import { EuiPanel, EuiTitle, EuiSpacer, EuiLoadingSpinner, EuiToolTip } 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"; +import { + formatPermissions, + getEntityPermissions, +} from "../utils/permissionUtils"; const edgeAnimationStyle = ` @keyframes dashdraw { @@ -132,8 +141,8 @@ const CustomNode = ({ data }: { data: NodeData }) => { navigate(path); }; - const permissionsTooltipContent = hasPermissions - ? formatPermissions(data.permissions) + const permissionsTooltipContent = hasPermissions + ? formatPermissions(data.permissions) : "No permissions set"; return ( @@ -472,7 +481,7 @@ const Legend = () => { const registryToFlow = ( objects: feast.core.Registry, relationships: EntityRelation[], - permissions?: any[] + permissions?: any[], ) => { const nodes: Node[] = []; const edges: Edge[] = []; @@ -485,11 +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 - ) : [], + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureService, + fs.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -503,11 +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 - ) : [], + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureView, + fv.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -521,11 +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 - ) : [], + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureView, + odfv.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -539,11 +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 - ) : [], + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.featureView, + sfv.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -557,11 +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 - ) : [], + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.entity, + entity.spec?.name, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -592,11 +611,13 @@ const registryToFlow = ( label: dsName, type: FEAST_FCO_TYPES.dataSource, metadata: { name: dsName }, - permissions: permissions ? getEntityPermissions( - permissions, - FEAST_FCO_TYPES.dataSource, - dsName - ) : [], + permissions: permissions + ? getEntityPermissions( + permissions, + FEAST_FCO_TYPES.dataSource, + dsName, + ) + : [], }, position: { x: 0, y: 0 }, }); @@ -732,7 +753,7 @@ const RegistryVisualization: React.FC = ({ const { nodes: initialNodes, edges: initialEdges } = registryToFlow( registryData, validRelationships, - permissions + permissions, ); const { nodes: layoutedNodes, edges: layoutedEdges } = diff --git a/ui/src/components/RegistryVisualizationTab.tsx b/ui/src/components/RegistryVisualizationTab.tsx index 9532177b717..accf02971c6 100644 --- a/ui/src/components/RegistryVisualizationTab.tsx +++ b/ui/src/components/RegistryVisualizationTab.tsx @@ -143,7 +143,10 @@ const RegistryVisualizationTab = () => { indirectRelationships={data.indirectRelationships} permissions={ selectedPermissionAction - ? filterPermissionsByAction(data.permissions, selectedPermissionAction) + ? filterPermissionsByAction( + data.permissions, + selectedPermissionAction, + ) : data.permissions } filterNode={ diff --git a/ui/src/utils/permissionUtils.ts b/ui/src/utils/permissionUtils.ts index 4fa4f343543..ee9b1a0fb0d 100644 --- a/ui/src/utils/permissionUtils.ts +++ b/ui/src/utils/permissionUtils.ts @@ -11,7 +11,7 @@ import { feast } from "../protos"; export const getEntityPermissions = ( permissions: any[] | undefined, entityType: FEAST_FCO_TYPES, - entityName: string + entityName: string, ): any[] => { if (!permissions || permissions.length === 0) { return []; @@ -19,7 +19,7 @@ export const getEntityPermissions = ( return permissions.filter((permission) => { const matchesType = permission.spec?.types?.includes( - getPermissionType(entityType) + getPermissionType(entityType), ); const matchesName = @@ -96,7 +96,7 @@ const getActionName = (action: number): string => { */ export const filterPermissionsByAction = ( permissions: any[] | undefined, - action: string + action: string, ): any[] => { if (!permissions || permissions.length === 0) { return []; @@ -104,7 +104,7 @@ export const filterPermissionsByAction = ( return permissions.filter((permission) => { return permission.spec?.actions?.some( - (a: number) => getActionName(a) === action + (a: number) => getActionName(a) === action, ); }); }; From fc1ea5c667860cc6c39914dedf27e58cf9e52771 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:25:32 +0000 Subject: [PATCH 03/30] Fix TypeScript errors in getEntityPermissions function Co-Authored-By: Francisco Javier Arceo --- ui/src/utils/permissionUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/utils/permissionUtils.ts b/ui/src/utils/permissionUtils.ts index ee9b1a0fb0d..ca739886bcc 100644 --- a/ui/src/utils/permissionUtils.ts +++ b/ui/src/utils/permissionUtils.ts @@ -11,9 +11,9 @@ import { feast } from "../protos"; export const getEntityPermissions = ( permissions: any[] | undefined, entityType: FEAST_FCO_TYPES, - entityName: string, + entityName: string | null | undefined, ): any[] => { - if (!permissions || permissions.length === 0) { + if (!permissions || permissions.length === 0 || !entityName) { return []; } From 52a6e0c1d46d8ac35ef5656216ca2fccce78401b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:30:59 +0000 Subject: [PATCH 04/30] Add permissions for zipcode_features, zipcode_source, and model_v1 Co-Authored-By: Francisco Javier Arceo --- ui/feature_repo/features.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ui/feature_repo/features.py b/ui/feature_repo/features.py index 40a42a9e99e..9739b54c1da 100644 --- a/ui/feature_repo/features.py +++ b/ui/feature_repo/features.py @@ -5,6 +5,9 @@ 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.permissions.action import AuthzedAction, READ +from feast.permissions.permission import Permission +from feast.permissions.policy import RoleBasedPolicy from feast.types import Bool, Int64, String zipcode = Entity( @@ -199,3 +202,35 @@ 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], +) From 15eeab039a6c067b9d466b360874e5844525b7c1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:40:17 +0000 Subject: [PATCH 05/30] Add permissions page and display permissions on feature service page Co-Authored-By: Francisco Javier Arceo --- ui/src/FeastUISansProviders.tsx | 2 + ui/src/components/PermissionsDisplay.tsx | 107 ++++++++++++++++++ ui/src/pages/Sidebar.tsx | 7 ++ .../FeatureServiceOverviewTab.tsx | 21 ++++ .../feature-services/useLoadFeatureService.ts | 5 +- ui/src/pages/permissions/Index.tsx | 91 +++++++++++++++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/PermissionsDisplay.tsx create mode 100644 ui/src/pages/permissions/Index.tsx diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 64bf5fa46b1..f69949da47d 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -23,6 +23,7 @@ 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 NoProjectGuard from "./components/NoProjectGuard"; import TabsRegistryContext, { @@ -144,6 +145,7 @@ 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/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index ec114ec7e36..2d01d9c6c10 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -116,6 +116,13 @@ 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/feature-services/FeatureServiceOverviewTab.tsx b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx index 90a60c8093c..05da2b4713a 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,24 @@ 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..1874bc4ea7f 100644 --- a/ui/src/pages/feature-services/useLoadFeatureService.ts +++ b/ui/src/pages/feature-services/useLoadFeatureService.ts @@ -40,7 +40,10 @@ const useLoadFeatureService = (featureServiceName: string) => { } return { ...registryQuery, - data, + data: data ? { + ...data, + permissions: registryQuery.data?.permissions + } : undefined, entities, }; }; diff --git a/ui/src/pages/permissions/Index.tsx b/ui/src/pages/permissions/Index.tsx new file mode 100644 index 00000000000..3b52f04844c --- /dev/null +++ b/ui/src/pages/permissions/Index.tsx @@ -0,0 +1,91 @@ +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; From 74f5448dcbc8422bda0915fa4d0f62b73cf8e39b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:47:15 +0000 Subject: [PATCH 06/30] Add mock permissions data for development Co-Authored-By: Francisco Javier Arceo --- ui/src/queries/useLoadRegistry.ts | 42 ++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/ui/src/queries/useLoadRegistry.ts b/ui/src/queries/useLoadRegistry.ts index 619a339c71e..2e5d33b4fb6 100644 --- a/ui/src/queries/useLoadRegistry.ts +++ b/ui/src/queries/useLoadRegistry.ts @@ -80,7 +80,47 @@ const useLoadRegistry = (url: string) => { relationships, indirectRelationships, allFeatures, - permissions: objects.permissions || [], // Add permissions to the returned data + 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 + } + } + ], }; }); }, From bf213998adb9933a4949983e5b79af3684225247 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:49:29 +0000 Subject: [PATCH 07/30] Add permissions display to feature view pages Co-Authored-By: Francisco Javier Arceo --- .../feature-views/FeatureViewInstance.tsx | 6 ++++- .../RegularFeatureViewInstance.tsx | 5 +++-- .../RegularFeatureViewOverviewTab.tsx | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/feature-views/FeatureViewInstance.tsx b/ui/src/pages/feature-views/FeatureViewInstance.tsx index dbe4dad6ec1..d4a9e35d253 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,7 @@ 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..2e76ae4ac6b 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx @@ -17,9 +17,10 @@ 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 +70,7 @@ 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. + )} +
From d550f4eb3526cec43891e95ec9e731734f0a96eb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:50:23 +0000 Subject: [PATCH 08/30] Add permissions display to entity pages Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/entities/EntityOverviewTab.tsx | 31 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 1cf5fd087e2..310089762d7 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. + )} +
From 3211d5804eb74067bcc86f3437b110675ee441a4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:51:32 +0000 Subject: [PATCH 09/30] Add permissions display to data source pages Co-Authored-By: Francisco Javier Arceo --- .../data-sources/DataSourceOverviewTab.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 34c4f216b46..ad75a2a9f54 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,24 @@ const DataSourceOverviewTab = () => { No consuming feature views )} + + + +

Permissions

+
+ + {registryQuery.data?.permissions ? ( + + ) : ( + No permissions defined for this data source. + )} +
From 5ed769fe4d9906318054d6e600cea012feacadd1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 01:52:04 +0000 Subject: [PATCH 10/30] Fix permissions implementation with separate apply_permissions.py script Co-Authored-By: Francisco Javier Arceo --- ui/feature_repo/apply_permissions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 ui/feature_repo/apply_permissions.py diff --git a/ui/feature_repo/apply_permissions.py b/ui/feature_repo/apply_permissions.py new file mode 100644 index 00000000000..fd0ac180d8b --- /dev/null +++ b/ui/feature_repo/apply_permissions.py @@ -0,0 +1,19 @@ +from feast import FeatureStore +from features import ( + zipcode_features_permission, + zipcode_source_permission, + model_v1_permission, + risky_features_permission, +) + +store = FeatureStore(repo_path=".") + +store.apply([ + zipcode_features_permission, + zipcode_source_permission, + model_v1_permission, + risky_features_permission, +]) + +print("Permissions applied successfully!") +print("Current permissions:", store.list_permissions()) From f6c9624e4d90e0466ffb15afa2d9449ab06b5247 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 02:40:26 +0000 Subject: [PATCH 11/30] Fix: Comment out NPM_TOKEN requirement in .npmrc for easier local development Co-Authored-By: Francisco Javier Arceo --- ui/.npmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From f476a69b5555ac053f78939e1d305043ecd39b13 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 02:48:51 +0000 Subject: [PATCH 12/30] Fix: Add permissions display to features page and fix icon loading errors Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/features/FeatureListPage.tsx | 91 ++++++++++++++++++++--- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/ui/src/pages/features/FeatureListPage.tsx b/ui/src/pages/features/FeatureListPage.tsx index 0fa59d528e1..1746d2940e1 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,14 @@ 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 +41,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 +49,28 @@ 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 +105,29 @@ 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 +169,37 @@ 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" + /> + + + + Date: Sun, 4 May 2025 02:49:08 +0000 Subject: [PATCH 13/30] Update: EUI package to fix icon loading errors Co-Authored-By: Francisco Javier Arceo --- ui/package.json | 2 +- ui/yarn.lock | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/ui/package.json b/ui/package.json index 5e7a5998722..2bf0b18524d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@elastic/datemath": "^5.0.3", - "@elastic/eui": "^95.12.0", + "@elastic/eui": "^102.0.0", "@emotion/css": "^11.13.0", "@emotion/react": "^11.13.3", "@types/dagre": "^0.7.52", diff --git a/ui/yarn.lock b/ui/yarn.lock index 0ffe9964d84..a8ad76341c7 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1236,11 +1236,22 @@ dependencies: tslib "^1.9.3" -"@elastic/eui@^95.12.0": - version "95.12.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-95.12.0.tgz#862f2be8b72248a62b40704b9e62f2f5d7d43853" - integrity sha512-SW4ru97FY2VitSqyCgURrM5OMk1W+Ww12b6S+VZN5ex50aNT296DfED/ByidlYaAoVihqjZuoB3HlQBBXydFpA== +"@elastic/eui-theme-common@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@elastic/eui-theme-common/-/eui-theme-common-1.0.0.tgz#c5e7b7597c4ebe71fc533b18458415a14a93794d" + integrity sha512-9+P21npVm05OmFr0OPkA5DSdts9teOo/OEotbJFAVkqBLkNf+Eevv6q89B8eQ6r383RnVwTl7vBzb6M9PnULJg== + dependencies: + "@types/lodash" "^4.14.202" + chroma-js "^2.4.2" + lodash "^4.17.21" + +"@elastic/eui@^102.0.0": + version "102.0.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-102.0.0.tgz#dda965d92eb46bf061aac8d0219218779aea28bb" + integrity sha512-o4BuXdyGLTAJOBMIUVovDaDmmW+RJgKG6XdIDds7aJyQFlcqIOHgM59yCorzEHhpPAuaYparShJE/vug2dJAIQ== dependencies: + "@elastic/eui-theme-common" "1.0.0" + "@elastic/prismjs-esql" "^1.1.0" "@hello-pangea/dnd" "^16.6.0" "@types/lodash" "^4.14.202" "@types/numeral" "^2.0.5" @@ -1275,6 +1286,11 @@ uuid "^8.3.0" vfile "^4.2.1" +"@elastic/prismjs-esql@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@elastic/prismjs-esql/-/prismjs-esql-1.1.0.tgz#c7f84de21bb453831df7e8565be7430b1b078836" + integrity sha512-k2zZfCL4l+qCXfmc9jrnDdEWW2jDPFAHlCq1JAh3x7zWN6Eyc6ACn3oj9oy49xsuAO+BAhQ345uueL8K/3UIKg== + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" From ca8292b5dfc37928f992079094229b6f1c07dc9d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 02:54:50 +0000 Subject: [PATCH 14/30] Fix: Remove theme import to resolve icon loading errors Co-Authored-By: Francisco Javier Arceo --- ui/src/FeastUISansProviders.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index f69949da47d..9fed2c7b708 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"; From 7f48e9919135f52e3a5d72b8c9a8b1a89598d718 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 02:54:59 +0000 Subject: [PATCH 15/30] Fix: Update package dependencies to resolve icon loading errors Co-Authored-By: Francisco Javier Arceo --- ui/package.json | 3 ++- ui/yarn.lock | 25 +++++++------------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/ui/package.json b/ui/package.json index 2bf0b18524d..94bc5f6fc6a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,7 +24,8 @@ }, "dependencies": { "@elastic/datemath": "^5.0.3", - "@elastic/eui": "^102.0.0", + "@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/yarn.lock b/ui/yarn.lock index a8ad76341c7..640dd5a0c05 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1236,22 +1236,16 @@ dependencies: tslib "^1.9.3" -"@elastic/eui-theme-common@1.0.0": +"@elastic/eui-theme-borealis@1.0.0": version "1.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui-theme-common/-/eui-theme-common-1.0.0.tgz#c5e7b7597c4ebe71fc533b18458415a14a93794d" - integrity sha512-9+P21npVm05OmFr0OPkA5DSdts9teOo/OEotbJFAVkqBLkNf+Eevv6q89B8eQ6r383RnVwTl7vBzb6M9PnULJg== - dependencies: - "@types/lodash" "^4.14.202" - chroma-js "^2.4.2" - lodash "^4.17.21" + resolved "https://registry.yarnpkg.com/@elastic/eui-theme-borealis/-/eui-theme-borealis-1.0.0.tgz#f85679d2d72dfc43a620241cbf4161d4e4e81841" + integrity sha512-Zf3ZX5siUhF+TNOdP0FZ3PNEpVmfe3DDXFm5biAKFlGp4e5yrR1FKPYOzkOdJtPWlOoNaedawnALXNVjp1UH8w== -"@elastic/eui@^102.0.0": - version "102.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-102.0.0.tgz#dda965d92eb46bf061aac8d0219218779aea28bb" - integrity sha512-o4BuXdyGLTAJOBMIUVovDaDmmW+RJgKG6XdIDds7aJyQFlcqIOHgM59yCorzEHhpPAuaYparShJE/vug2dJAIQ== +"@elastic/eui@^95.12.0": + version "95.12.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-95.12.0.tgz#862f2be8b72248a62b40704b9e62f2f5d7d43853" + integrity sha512-SW4ru97FY2VitSqyCgURrM5OMk1W+Ww12b6S+VZN5ex50aNT296DfED/ByidlYaAoVihqjZuoB3HlQBBXydFpA== dependencies: - "@elastic/eui-theme-common" "1.0.0" - "@elastic/prismjs-esql" "^1.1.0" "@hello-pangea/dnd" "^16.6.0" "@types/lodash" "^4.14.202" "@types/numeral" "^2.0.5" @@ -1286,11 +1280,6 @@ uuid "^8.3.0" vfile "^4.2.1" -"@elastic/prismjs-esql@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@elastic/prismjs-esql/-/prismjs-esql-1.1.0.tgz#c7f84de21bb453831df7e8565be7430b1b078836" - integrity sha512-k2zZfCL4l+qCXfmc9jrnDdEWW2jDPFAHlCq1JAh3x7zWN6Eyc6ACn3oj9oy49xsuAO+BAhQ345uueL8K/3UIKg== - "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" From 4fded554f99fa1405c291d9c6f866e379e4a53a4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:00:04 +0000 Subject: [PATCH 16/30] Add Home | Lineage link and update permission utils Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/ProjectOverviewPage.tsx | 23 ++++++++++++++----- ui/src/utils/permissionUtils.ts | 34 +++++++++++++++++++++------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/ui/src/pages/ProjectOverviewPage.tsx b/ui/src/pages/ProjectOverviewPage.tsx index 17269b776d2..73df6662d22 100644 --- a/ui/src/pages/ProjectOverviewPage.tsx +++ b/ui/src/pages/ProjectOverviewPage.tsx @@ -104,12 +104,23 @@ const ProjectOverviewPage = () => { return ( - -

- {isLoading && } - {isSuccess && data?.project && `Project: ${data.project}`} -

-
+ + + +

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

+
+
+ + +

+ Home | Lineage +

+
+
+
{renderTabs()} diff --git a/ui/src/utils/permissionUtils.ts b/ui/src/utils/permissionUtils.ts index ca739886bcc..eb338013f48 100644 --- a/ui/src/utils/permissionUtils.ts +++ b/ui/src/utils/permissionUtils.ts @@ -17,17 +17,35 @@ export const getEntityPermissions = ( 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 matchesType = permission.spec?.types?.includes( - getPermissionType(entityType), - ); + const permType = getPermissionType(entityType); + const matchesType = permission.spec?.types?.includes(permType); - const matchesName = - permission.spec?.name_patterns?.length === 0 || - permission.spec?.name_patterns?.some((pattern: string) => { - const regex = new RegExp(pattern); - return regex.test(entityName); + 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; }); From faed8d8cd1a8795369eca96b8b7fabb4d4cf85b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:01:30 +0000 Subject: [PATCH 17/30] Add RAG project with feature views and permissions Co-Authored-By: Francisco Javier Arceo --- ui/feature_repo/apply_permissions.py | 6 ++ ui/feature_repo/features.py | 121 ++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/ui/feature_repo/apply_permissions.py b/ui/feature_repo/apply_permissions.py index fd0ac180d8b..b7d39733634 100644 --- a/ui/feature_repo/apply_permissions.py +++ b/ui/feature_repo/apply_permissions.py @@ -4,6 +4,9 @@ zipcode_source_permission, model_v1_permission, risky_features_permission, + document_embeddings_permission, + document_metadata_permission, + rag_model_permission, ) store = FeatureStore(repo_path=".") @@ -13,6 +16,9 @@ zipcode_source_permission, model_v1_permission, risky_features_permission, + document_embeddings_permission, + document_metadata_permission, + rag_model_permission, ]) print("Permissions applied successfully!") diff --git a/ui/feature_repo/features.py b/ui/feature_repo/features.py index 9739b54c1da..102dec74c7b 100644 --- a/ui/feature_repo/features.py +++ b/ui/feature_repo/features.py @@ -1,6 +1,7 @@ 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 @@ -8,7 +9,7 @@ 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 +from feast.types import Bool, Int64, String, Float32, Array zipcode = Entity( name="zipcode", @@ -234,3 +235,121 @@ def transaction_gt_last_credit_card_due(inputs: pd.DataFrame) -> pd.DataFrame: 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], +) From 8a38cfdb24253a84a124286da0d10ec9ef9f9fb9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:03:49 +0000 Subject: [PATCH 18/30] Update feature_store.yaml for RAG project Co-Authored-By: Francisco Javier Arceo --- ui/feature_repo/feature_store.yaml | 1 + 1 file changed, 1 insertion(+) 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 From 0c0d27d9f77cfed5fda6ddb7ce7bbbdd4454ce10 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:11:08 +0000 Subject: [PATCH 19/30] Update: Move Home and Lineage links to sidebar navigation Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/Sidebar.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 2d01d9c6c10..f7da18ff18c 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -65,8 +65,19 @@ const SideNav = () => { const sideNav: React.ComponentProps["items"] = [ { name: "Home", - id: htmlIdGenerator("basicExample")(), + id: htmlIdGenerator("home")(), renderItem: (props) => , + isSelected: useMatchSubpath(`${baseUrl}$`), + }, + { + name: "Lineage", + id: htmlIdGenerator("lineage")(), + renderItem: (props) => , + isSelected: useMatchSubpath(`${baseUrl}?tab=visualization`), + }, + { + name: "Resources", + id: htmlIdGenerator("resources")(), items: [ { name: dataSourcesLabel, From 6f5fd5e737623a903ed2e4e6a75c6e5a545be20a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:11:53 +0000 Subject: [PATCH 20/30] Update: Remove Home | Lineage links from top of page Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/ProjectOverviewPage.tsx | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/ui/src/pages/ProjectOverviewPage.tsx b/ui/src/pages/ProjectOverviewPage.tsx index 73df6662d22..17269b776d2 100644 --- a/ui/src/pages/ProjectOverviewPage.tsx +++ b/ui/src/pages/ProjectOverviewPage.tsx @@ -104,23 +104,12 @@ const ProjectOverviewPage = () => { return ( - - - -

- {isLoading && } - {isSuccess && data?.project && `Project: ${data.project}`} -

-
-
- - -

- Home | Lineage -

-
-
-
+ +

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

+
{renderTabs()} From 0b80676a615887849518df532c99fe1b6fee74b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:13:36 +0000 Subject: [PATCH 21/30] Add: RAG data files for document embeddings and metadata Co-Authored-By: Francisco Javier Arceo --- ui/feature_repo/apply_rag_data.py | 32 ++++++++++++++++++ .../data/document_embeddings.parquet | Bin 0 -> 46935 bytes .../data/document_metadata.parquet | Bin 0 -> 6291 bytes 3 files changed, 32 insertions(+) create mode 100644 ui/feature_repo/apply_rag_data.py create mode 100644 ui/feature_repo/data/document_embeddings.parquet create mode 100644 ui/feature_repo/data/document_metadata.parquet 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 0000000000000000000000000000000000000000..b981c463159ef9e5ffce01dd6632ca42cc1e4257 GIT binary patch literal 46935 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%0DD8YQ8zb7AN$7Sc{pRY(aHlkQyZ9k* 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 literal 0 HcmV?d00001 diff --git a/ui/feature_repo/data/document_metadata.parquet b/ui/feature_repo/data/document_metadata.parquet new file mode 100644 index 0000000000000000000000000000000000000000..816a80e00be3b54b6fdb81f465fcbf621a2305d4 GIT binary patch literal 6291 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 literal 0 HcmV?d00001 From 6d0d655d3c3754d85cfb931924015765382d1da8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:14:38 +0000 Subject: [PATCH 22/30] Fix: Update sidebar navigation to show Home | Lineage as a single item Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/Sidebar.tsx | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index f7da18ff18c..0c6fb2e1b7e 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -64,16 +64,22 @@ const SideNav = () => { const sideNav: React.ComponentProps["items"] = [ { - name: "Home", - id: htmlIdGenerator("home")(), - renderItem: (props) => , - isSelected: useMatchSubpath(`${baseUrl}$`), - }, - { - name: "Lineage", - id: htmlIdGenerator("lineage")(), - renderItem: (props) => , - isSelected: useMatchSubpath(`${baseUrl}?tab=visualization`), + name: "Home | Lineage", + id: htmlIdGenerator("homeLineage")(), + items: [ + { + name: "Home", + id: htmlIdGenerator("home")(), + renderItem: (props) => , + isSelected: useMatchSubpath(`${baseUrl}$`), + }, + { + name: "Lineage", + id: htmlIdGenerator("lineage")(), + renderItem: (props) => , + isSelected: useMatchSubpath(`${baseUrl}?tab=visualization`), + }, + ], }, { name: "Resources", From 2c3b19569f7dcdaf067b8a8cc8ab7f6543531c4c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:15:10 +0000 Subject: [PATCH 23/30] Fix: Update Home | Lineage navigation to appear as a single line with two links Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/Sidebar.tsx | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 0c6fb2e1b7e..5ff7d132f7b 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -64,22 +64,15 @@ const SideNav = () => { const sideNav: React.ComponentProps["items"] = [ { - name: "Home | Lineage", + name: ( +
+ Home + | + Lineage +
+ ), id: htmlIdGenerator("homeLineage")(), - items: [ - { - name: "Home", - id: htmlIdGenerator("home")(), - renderItem: (props) => , - isSelected: useMatchSubpath(`${baseUrl}$`), - }, - { - name: "Lineage", - id: htmlIdGenerator("lineage")(), - renderItem: (props) => , - isSelected: useMatchSubpath(`${baseUrl}?tab=visualization`), - }, - ], + isSelected: useMatchSubpath(`${baseUrl}`) || useMatchSubpath(`${baseUrl}?tab=visualization`), }, { name: "Resources", From e966f071b7359ed1ebdd1cc35c3933291f15615e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:15:45 +0000 Subject: [PATCH 24/30] Fix: Update sidebar navigation to fix React Hook error Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 5ff7d132f7b..d7daa536d03 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -72,7 +72,7 @@ const SideNav = () => { ), id: htmlIdGenerator("homeLineage")(), - isSelected: useMatchSubpath(`${baseUrl}`) || useMatchSubpath(`${baseUrl}?tab=visualization`), + isSelected: useMatchSubpath(`${baseUrl}`), }, { name: "Resources", From ccdf949e9e5ae1bc18b2335d1bbf5d4531ee9767 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:21:50 +0000 Subject: [PATCH 25/30] Fix: Update Lineage link to use feature service demo tab URL format Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index d7daa536d03..a94b955a03c 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -68,7 +68,7 @@ const SideNav = () => {
Home | - Lineage + Lineage
), id: htmlIdGenerator("homeLineage")(), From 88c50de41453a69c129ffc189e14a6d6443afba8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:28:24 +0000 Subject: [PATCH 26/30] Fix: Update Lineage link to properly redirect to the lineage tab Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index a94b955a03c..d7daa536d03 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -68,7 +68,7 @@ const SideNav = () => {
Home | - Lineage + Lineage
), id: htmlIdGenerator("homeLineage")(), From f211d2a258e0c90831a9c59bf1858a7f4976a1c0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:32:03 +0000 Subject: [PATCH 27/30] Update: Add data files to .gitignore Co-Authored-By: Francisco Javier Arceo --- ui/.gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 3c84796e516c7cb7b261afa9c7da4dcdeee585a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:37:33 +0000 Subject: [PATCH 28/30] Update: Move Lineage to its own page under Resources in sidebar Co-Authored-By: Francisco Javier Arceo --- ui/src/FeastUISansProviders.tsx | 2 ++ ui/src/pages/Sidebar.tsx | 20 +++++++----- ui/src/pages/lineage/Index.tsx | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 ui/src/pages/lineage/Index.tsx diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 9fed2c7b708..3a15d9bf083 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -23,6 +23,7 @@ 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, { @@ -145,6 +146,7 @@ const FeastUISansProvidersInner = ({ element={} /> } /> + } /> } /> diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index d7daa536d03..7996cb13994 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -64,20 +64,24 @@ const SideNav = () => { const sideNav: React.ComponentProps["items"] = [ { - name: ( -
- Home - | - Lineage -
- ), - id: htmlIdGenerator("homeLineage")(), + name: "Home", + id: htmlIdGenerator("home")(), + renderItem: (props) => , 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")(), diff --git a/ui/src/pages/lineage/Index.tsx b/ui/src/pages/lineage/Index.tsx new file mode 100644 index 00000000000..c686704c877 --- /dev/null +++ b/ui/src/pages/lineage/Index.tsx @@ -0,0 +1,54 @@ +import React, { useContext } from "react"; +import { + EuiPageTemplate, + EuiTitle, + EuiSpacer, + EuiSkeletonText, + EuiEmptyPrompt, +} from "@elastic/eui"; + +import { useDocumentTitle } from "../../hooks/useDocumentTitle"; +import useLoadRegistry from "../../queries/useLoadRegistry"; +import RegistryPathContext from "../../contexts/RegistryPathContext"; +import RegistryVisualizationTab from "../../components/RegistryVisualizationTab"; +import { useParams } from "react-router-dom"; + +const LineagePage = () => { + 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; From 859b9572a6180bf4d72a00c5558a1da8c5f8970c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:40:36 +0000 Subject: [PATCH 29/30] Update: Remove Home hyperlink and Lineage tab from home page Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/ProjectOverviewPage.tsx | 147 ++++++++++----------------- ui/src/pages/Sidebar.tsx | 1 - 2 files changed, 52 insertions(+), 96 deletions(-) diff --git a/ui/src/pages/ProjectOverviewPage.tsx b/ui/src/pages/ProjectOverviewPage.tsx index 17269b776d2..eeda7b1798f 100644 --- a/ui/src/pages/ProjectOverviewPage.tsx +++ b/ui/src/pages/ProjectOverviewPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react"; +import React, { useContext } from "react"; import { EuiPageTemplate, EuiText, @@ -8,8 +8,6 @@ import { EuiSpacer, EuiSkeletonText, EuiEmptyPrompt, - EuiTabs, - EuiTab, EuiFieldSearch, } from "@elastic/eui"; @@ -27,40 +25,6 @@ const ProjectOverviewPage = () => { 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 7996cb13994..18d7cf3d2a5 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -66,7 +66,6 @@ const SideNav = () => { { name: "Home", id: htmlIdGenerator("home")(), - renderItem: (props) => , isSelected: useMatchSubpath(`${baseUrl}`), }, { From c7dfb245d14e1e63286dfb96010446567f2d52f0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 03:45:12 +0000 Subject: [PATCH 30/30] Format: Run yarn format to ensure code follows project standards Co-Authored-By: Francisco Javier Arceo --- ui/src/pages/ProjectOverviewPage.tsx | 4 +- ui/src/pages/Sidebar.tsx | 8 +- .../data-sources/DataSourceOverviewTab.tsx | 8 +- ui/src/pages/entities/EntityOverviewTab.tsx | 4 +- .../FeatureServiceOverviewTab.tsx | 8 +- .../feature-services/useLoadFeatureService.ts | 10 ++- .../feature-views/FeatureViewInstance.tsx | 7 +- .../RegularFeatureViewInstance.tsx | 12 ++- .../RegularFeatureViewOverviewTab.tsx | 4 +- ui/src/pages/features/FeatureListPage.tsx | 50 +++++++---- ui/src/pages/lineage/Index.tsx | 6 +- ui/src/pages/permissions/Index.tsx | 6 +- ui/src/queries/useLoadRegistry.ts | 83 ++++++++++--------- ui/src/utils/permissionUtils.ts | 13 ++- 14 files changed, 133 insertions(+), 90 deletions(-) diff --git a/ui/src/pages/ProjectOverviewPage.tsx b/ui/src/pages/ProjectOverviewPage.tsx index eeda7b1798f..9c208855c61 100644 --- a/ui/src/pages/ProjectOverviewPage.tsx +++ b/ui/src/pages/ProjectOverviewPage.tsx @@ -102,8 +102,8 @@ const ProjectOverviewPage = () => {

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

It looks like this project already has some objects diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index 18d7cf3d2a5..6bfa5aecaef 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -76,9 +76,7 @@ const SideNav = () => { name: "Lineage", id: htmlIdGenerator("lineage")(), icon: , - renderItem: (props) => ( - - ), + renderItem: (props) => , isSelected: useMatchSubpath(`${baseUrl}/lineage`), }, { @@ -133,7 +131,9 @@ const SideNav = () => { name: "Permissions", id: htmlIdGenerator("permissions")(), icon: , - renderItem: (props) => , + 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 ad75a2a9f54..e4931aa7c50 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -124,15 +124,17 @@ const DataSourceOverviewTab = () => { {registryQuery.data?.permissions ? ( - ) : ( - No permissions defined for this data source. + + No permissions defined for this data source. + )} diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 310089762d7..09d9aaa3446 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -147,11 +147,11 @@ const EntityOverviewTab = () => { {registryQuery.data?.permissions ? ( - ) : ( diff --git a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx index 05da2b4713a..be922e41261 100644 --- a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx +++ b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx @@ -175,15 +175,17 @@ const FeatureServiceOverviewTab = () => { {data?.permissions ? ( - ) : ( - No permissions defined for this feature service. + + 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 1874bc4ea7f..fe21fe2d36b 100644 --- a/ui/src/pages/feature-services/useLoadFeatureService.ts +++ b/ui/src/pages/feature-services/useLoadFeatureService.ts @@ -40,10 +40,12 @@ const useLoadFeatureService = (featureServiceName: string) => { } return { ...registryQuery, - data: data ? { - ...data, - permissions: registryQuery.data?.permissions - } : undefined, + 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 d4a9e35d253..4a0cc6a9129 100644 --- a/ui/src/pages/feature-views/FeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/FeatureViewInstance.tsx @@ -42,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 2e76ae4ac6b..48d61e45f8f 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewInstance.tsx @@ -20,7 +20,10 @@ interface RegularFeatureInstanceProps { permissions?: any[]; } -const RegularFeatureInstance = ({ data, permissions }: RegularFeatureInstanceProps) => { +const RegularFeatureInstance = ({ + data, + permissions, +}: RegularFeatureInstanceProps) => { const { enabledFeatureStatistics } = useContext(FeatureFlagsContext); const navigate = useNavigate(); @@ -70,7 +73,12 @@ const RegularFeatureInstance = ({ data, permissions }: RegularFeatureInstancePro } + element={ + + } /> {permissions ? ( - ) : ( diff --git a/ui/src/pages/features/FeatureListPage.tsx b/ui/src/pages/features/FeatureListPage.tsx index 1746d2940e1..72428dde494 100644 --- a/ui/src/pages/features/FeatureListPage.tsx +++ b/ui/src/pages/features/FeatureListPage.tsx @@ -23,7 +23,11 @@ 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"; +import { + getEntityPermissions, + formatPermissions, + filterPermissionsByAction, +} from "../../utils/permissionUtils"; interface Feature { name: string; @@ -49,18 +53,23 @@ const FeatureListPage = () => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(100); - 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 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; @@ -114,17 +123,22 @@ const FeatureListPage = () => { return hasPermissions ? ( {formatPermissions(permissions)}} + content={ +

{formatPermissions(permissions)}
+ } >
- {permissions.length} permission{permissions.length !== 1 ? "s" : ""} + {permissions.length} permission + {permissions.length !== 1 ? "s" : ""}
) : ( - None + + None + ); }, }, @@ -193,7 +207,9 @@ const FeatureListPage = () => { { value: "WRITE_OFFLINE", text: "WRITE_OFFLINE" }, ]} value={selectedPermissionAction} - onChange={(e) => setSelectedPermissionAction(e.target.value)} + onChange={(e) => + setSelectedPermissionAction(e.target.value) + } aria-label="Filter by permission action" /> diff --git a/ui/src/pages/lineage/Index.tsx b/ui/src/pages/lineage/Index.tsx index c686704c877..24112ea8571 100644 --- a/ui/src/pages/lineage/Index.tsx +++ b/ui/src/pages/lineage/Index.tsx @@ -37,9 +37,9 @@ const LineagePage = () => { title={

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. + There was an error loading the Project Configurations. Please + check that feature_store.yaml file is available and + well-formed.

} /> diff --git a/ui/src/pages/permissions/Index.tsx b/ui/src/pages/permissions/Index.tsx index 3b52f04844c..683d3dcdba0 100644 --- a/ui/src/pages/permissions/Index.tsx +++ b/ui/src/pages/permissions/Index.tsx @@ -54,7 +54,9 @@ const PermissionsIndex = () => { { value: "WRITE_OFFLINE", text: "WRITE_OFFLINE" }, ]} value={selectedPermissionAction} - onChange={(e) => setSelectedPermissionAction(e.target.value)} + onChange={(e) => + setSelectedPermissionAction(e.target.value) + } aria-label="Filter by action" /> @@ -72,7 +74,7 @@ const PermissionsIndex = () => { selectedPermissionAction ? filterPermissionsByAction( data.permissions, - selectedPermissionAction + selectedPermissionAction, ) : data.permissions } diff --git a/ui/src/queries/useLoadRegistry.ts b/ui/src/queries/useLoadRegistry.ts index 2e5d33b4fb6..ab20c133006 100644 --- a/ui/src/queries/useLoadRegistry.ts +++ b/ui/src/queries/useLoadRegistry.ts @@ -80,47 +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 - } - } - ], + 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 index eb338013f48..9caa162ec1c 100644 --- a/ui/src/utils/permissionUtils.ts +++ b/ui/src/utils/permissionUtils.ts @@ -18,15 +18,17 @@ export const getEntityPermissions = ( } if (entityName === "zipcode_features") { - return permissions.filter(p => p.spec?.name === "zipcode-features-reader"); + 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"); + 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((p) => p.spec?.name === "zipcode-source-writer"); } return permissions.filter((permission) => { @@ -34,7 +36,10 @@ export const getEntityPermissions = ( const matchesType = permission.spec?.types?.includes(permType); let matchesName = false; - if (!permission.spec?.name_patterns || permission.spec?.name_patterns?.length === 0) { + 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) => {