From f8fd928a630978bbe8fe489c8b38306436afe8ad Mon Sep 17 00:00:00 2001 From: max Date: Mon, 23 Feb 2026 13:55:11 -0500 Subject: [PATCH] feat(RHINENG-23947): Add Kessel perms Integrate Kessel permission checks behind the patch-frontend.kessel-enabled feature flag. When Kessel is enabled, permission checks use the Kessel SDK (useSelfAccessCheck) against workspace resources instead of RBAC v1. When disabled, behavior is unchanged. Permission mapping (from patch.ksl): - patch:*:read -> patch_system_view - patch:*:* -> patch_system_edit - patch:template:write -> patch_template_edit Changes: - Add AccessCheck.Provider and QueryClientProvider to App.js - Create usePermissionCheck hook that toggles between RBAC v1 and Kessel based on feature flag - Create useKesselWorkspaces hook to fetch default workspace ID from /api/rbac/v2/workspaces - Replace direct usePermissionsWithContext calls in Routes, SystemsTable, PatchSet, PatchSetDetail, and WithPermission with usePermissionCheck - Update @project-kessel/react-kessel-access-check to ^0.3.1 and add @tanstack/react-query - Add KESSEL_API_BASE_URL and RBAC_API_BASE_V2 constants Co-authored-by: Cursor --- config/setupTests.js | 19 +++++ package-lock.json | 35 +++++++- package.json | 3 +- src/App.js | 17 ++-- .../WithPermission/WithPermission.js | 15 ++-- src/Routes.js | 4 +- src/SmartComponents/PatchSet/PatchSet.js | 4 +- .../PatchSetDetail/PatchSetDetail.js | 4 +- src/SmartComponents/Systems/SystemsTable.js | 4 +- src/Utilities/constants.js | 4 + src/Utilities/hooks/useFeatureFlag.js | 5 +- src/Utilities/hooks/useKesselWorkspaces.js | 36 +++++++++ src/Utilities/hooks/usePermissionCheck.js | 81 +++++++++++++++++++ 13 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 src/Utilities/hooks/useKesselWorkspaces.js create mode 100644 src/Utilities/hooks/usePermissionCheck.js diff --git a/config/setupTests.js b/config/setupTests.js index 03ff480d3..57f301303 100644 --- a/config/setupTests.js +++ b/config/setupTests.js @@ -57,4 +57,23 @@ jest.mock('../src/Utilities/hooks/useRemediationDataProvider', () => ({ jest.mock('../src/Utilities/hooks/useFeatureFlag', () => jest.fn()); +jest.mock('../src/Utilities/hooks/usePermissionCheck', () => ({ + __esModule: true, + default: () => ({ hasAccess: true, isLoading: false }), + useRbacV1Permissions: () => ({ hasAccess: true, isLoading: false }), + useKesselPermissions: () => ({ hasAccess: true, isLoading: false }), + PERMISSION_MAP: { + 'patch:*:read': 'patch_system_view', + 'patch:*:*': 'patch_system_edit', + 'patch:template:write': 'patch_template_edit', + }, +})); + +jest.mock('@project-kessel/react-kessel-access-check', () => ({ + AccessCheck: { + Provider: ({ children }) => <>{children}, + }, + useSelfAccessCheck: () => ({ data: null, loading: false, error: null }), +})); + global.React = React; diff --git a/package-lock.json b/package-lock.json index f1b0799a4..d4fd58ebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@patternfly/react-core": "^6.3.1", "@patternfly/react-icons": "^6.3.1", "@patternfly/react-table": "^6.4.1", - "@project-kessel/react-kessel-access-check": "^0.0.2", + "@project-kessel/react-kessel-access-check": "^0.3.1", "@redhat-cloud-services/frontend-components": "^7.0.4", "@redhat-cloud-services/frontend-components-notifications": "^6.1.13", "@redhat-cloud-services/frontend-components-remediations": "^4.0.17", @@ -25,6 +25,7 @@ "@redhat-cloud-services/javascript-clients-shared": "^2.0.0", "@scalprum/react-core": "^0.7.1", "@sentry/webpack-plugin": "^3.1.0", + "@tanstack/react-query": "^5.90.5", "@types/dockerode": "^3.3.47", "@unleash/proxy-client-react": "^3.5.0", "axios": "^1.13.5", @@ -5424,9 +5425,9 @@ } }, "node_modules/@project-kessel/react-kessel-access-check": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@project-kessel/react-kessel-access-check/-/react-kessel-access-check-0.0.2.tgz", - "integrity": "sha512-jTPHrgByktaSfOO4BmH2oLKuKxbgvL4SiaszFGlo7aJyXButj1chcOQS7UHEzzz8psmc0ZSPm/UQGovXCKp1ig==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@project-kessel/react-kessel-access-check/-/react-kessel-access-check-0.3.2.tgz", + "integrity": "sha512-yYghByDgWo0IpbBb1S5Pa+0uTTzrUbDwGKhEq7BycG3mKX32hhXB0PjbUXMHOWBGptruw68Fc1lPbsRQ7L4lTw==", "license": "Apache-2.0", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6834,6 +6835,32 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 9f03e16b4..09e081907 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@patternfly/react-core": "^6.3.1", "@patternfly/react-icons": "^6.3.1", "@patternfly/react-table": "^6.4.1", - "@project-kessel/react-kessel-access-check": "^0.0.2", + "@project-kessel/react-kessel-access-check": "^0.3.1", "@redhat-cloud-services/frontend-components": "^7.0.4", "@redhat-cloud-services/frontend-components-notifications": "^6.1.13", "@redhat-cloud-services/frontend-components-remediations": "^4.0.17", @@ -19,6 +19,7 @@ "@redhat-cloud-services/host-inventory-client": "^4.1.7", "@redhat-cloud-services/javascript-clients-shared": "^2.0.0", "@scalprum/react-core": "^0.7.1", + "@tanstack/react-query": "^5.90.5", "@sentry/webpack-plugin": "^3.1.0", "@types/dockerode": "^3.3.47", "@unleash/proxy-client-react": "^3.5.0", diff --git a/src/App.js b/src/App.js index 9e3685c6e..0ba926511 100644 --- a/src/App.js +++ b/src/App.js @@ -1,14 +1,19 @@ import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { NotificationsProvider } from '@redhat-cloud-services/frontend-components-notifications'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; import '@redhat-cloud-services/frontend-components-notifications/index.css'; import { RBACProvider } from '@redhat-cloud-services/frontend-components/RBACProvider'; +import { AccessCheck } from '@project-kessel/react-kessel-access-check'; import { changeGlobalTags, changeProfile, globalFilter } from './store/Actions/Actions'; import { mapGlobalFilters } from './Utilities/Helpers'; +import { KESSEL_API_BASE_URL } from './Utilities/constants'; import './App.scss'; import Routes from './Routes'; +const queryClient = new QueryClient(); + const App = () => { const dispatch = useDispatch(); const chrome = useChrome(); @@ -40,13 +45,15 @@ const App = () => { }, []); return ( - + - - - + + + + + - + ); }; diff --git a/src/PresentationalComponents/WithPermission/WithPermission.js b/src/PresentationalComponents/WithPermission/WithPermission.js index e645a366a..7c6093963 100644 --- a/src/PresentationalComponents/WithPermission/WithPermission.js +++ b/src/PresentationalComponents/WithPermission/WithPermission.js @@ -1,20 +1,21 @@ import React from 'react'; import propTypes from 'prop-types'; import { NotAuthorized } from '@redhat-cloud-services/frontend-components/NotAuthorized'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import usePermissionCheck from '../../Utilities/hooks/usePermissionCheck'; -const WithPermission = ({ children, requiredPermissions = [] }) => { - const { hasAccess, isLoading } = usePermissionsWithContext(requiredPermissions); - if (!isLoading) { - return hasAccess ? children : ; - } else { - return ''; +const WithPermission = ({ children, requiredPermissions = [], hide = false }) => { + const { hasAccess, isLoading } = usePermissionCheck(requiredPermissions); + + if (isLoading) { + return null; } + return hasAccess ? children : !hide && ; }; WithPermission.propTypes = { children: propTypes.node, requiredPermissions: propTypes.array, + hide: propTypes.bool, }; export default WithPermission; diff --git a/src/Routes.js b/src/Routes.js index 85d4b4da9..1e6fcf536 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -1,15 +1,15 @@ import { Bullseye, Spinner } from '@patternfly/react-core'; import { NotAuthorized } from '@redhat-cloud-services/frontend-components/NotAuthorized'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; import AsyncComponent from '@redhat-cloud-services/frontend-components/AsyncComponent'; import axios from 'axios'; import PropTypes from 'prop-types'; import React, { lazy, Suspense, useEffect, useState } from 'react'; import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { NavigateToSystem } from './Utilities/NavigateToSystem'; +import usePermissionCheck from './Utilities/hooks/usePermissionCheck'; const PermissionRoute = ({ requiredPermissions = [] }) => { - const { hasAccess, isLoading } = usePermissionsWithContext(requiredPermissions); + const { hasAccess, isLoading } = usePermissionCheck(requiredPermissions); if (!isLoading) { return hasAccess ? : ; } else { diff --git a/src/SmartComponents/PatchSet/PatchSet.js b/src/SmartComponents/PatchSet/PatchSet.js index f719402bb..702974d0a 100644 --- a/src/SmartComponents/PatchSet/PatchSet.js +++ b/src/SmartComponents/PatchSet/PatchSet.js @@ -35,7 +35,7 @@ import { } from './PatchSetAssets'; import PatchSetWizard from '../PatchSetWizard/PatchSetWizard'; import { patchSetDeleteNotifications } from '../../Utilities/constants'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import usePermissionCheck from '../../Utilities/hooks/usePermissionCheck'; import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import { Icon, Popover } from '@patternfly/react-core'; import DeleteSetModal from '../Modals/DeleteSetModal'; @@ -133,7 +133,7 @@ const PatchSet = () => { }); }; - const { hasAccess } = usePermissionsWithContext(['patch:*:*', 'patch:template:write']); + const { hasAccess } = usePermissionCheck(['patch:*:*', 'patch:template:write']); const CreatePatchSetButton = createPatchSetButton(setPatchSetState, hasAccess); const actionsConfig = patchSetRowActions(openPatchSetEditModal, openPatchDeleteModal); diff --git a/src/SmartComponents/PatchSetDetail/PatchSetDetail.js b/src/SmartComponents/PatchSetDetail/PatchSetDetail.js index 7085f4272..828f7c5df 100644 --- a/src/SmartComponents/PatchSetDetail/PatchSetDetail.js +++ b/src/SmartComponents/PatchSetDetail/PatchSetDetail.js @@ -49,7 +49,7 @@ import { } from '../../Utilities/Helpers'; import PatchSetWizard from '../PatchSetWizard/PatchSetWizard'; import { patchSetDetailRowActions } from '../PatchSet/PatchSetAssets'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import usePermissionCheck from '../../Utilities/hooks/usePermissionCheck'; import UnassignSystemsModal from '../Modals/UnassignSystemsModal'; import { TableVariant } from '@patternfly/react-table'; import { InventoryTable } from '@redhat-cloud-services/frontend-components/Inventory'; @@ -119,7 +119,7 @@ const PatchSetDetail = () => { ({ PatchSetDetailSystemsStore }) => PatchSetDetailSystemsStore?.templateHasSystems, ); - const { hasAccess } = usePermissionsWithContext(['patch:*:*', 'patch:template:write']); + const { hasAccess } = usePermissionCheck(['patch:*:*', 'patch:template:write']); const patchSetName = templateDetails.data.attributes.name; diff --git a/src/SmartComponents/Systems/SystemsTable.js b/src/SmartComponents/Systems/SystemsTable.js index e86ee328e..f32a7bf17 100644 --- a/src/SmartComponents/Systems/SystemsTable.js +++ b/src/SmartComponents/Systems/SystemsTable.js @@ -29,7 +29,7 @@ import { mergeInventoryColumns, } from '../../Utilities/SystemsHelpers'; import { combineReducers } from 'redux'; -import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import usePermissionCheck from '../../Utilities/hooks/usePermissionCheck'; import propTypes from 'prop-types'; const SystemsTable = ({ @@ -52,7 +52,7 @@ const SystemsTable = ({ const areAllSelected = useSelector(({ SystemsStore }) => SystemsStore?.areAllSelected); const queryParams = useSelector(({ SystemsStore }) => SystemsStore?.queryParams || {}); - const { hasAccess: hasTemplateAccess } = usePermissionsWithContext([ + const { hasAccess: hasTemplateAccess } = usePermissionCheck([ 'patch:*:*', 'patch:template:write', ]); diff --git a/src/Utilities/constants.js b/src/Utilities/constants.js index f990d86a8..200dfb8df 100644 --- a/src/Utilities/constants.js +++ b/src/Utilities/constants.js @@ -307,8 +307,12 @@ export const patchSetDeleteNotifications = (templateName) => ({ export const multiValueFilters = ['installed_evra', 'os', 'creator', 'status', 'group_name']; +export const KESSEL_API_BASE_URL = '/api/kessel/v1beta2'; +export const RBAC_API_BASE_V2 = '/api/rbac/v2'; + export const featureFlags = { patch_set: 'patch.patch_set', + kessel_enabled: 'patch-frontend.kessel-enabled', }; export const NO_ADVISORIES_TEXT = diff --git a/src/Utilities/hooks/useFeatureFlag.js b/src/Utilities/hooks/useFeatureFlag.js index a125bbaf9..f9be4525e 100644 --- a/src/Utilities/hooks/useFeatureFlag.js +++ b/src/Utilities/hooks/useFeatureFlag.js @@ -3,10 +3,9 @@ import { useFlag, useFlagsStatus } from '@unleash/proxy-client-react'; const useFeatureFlag = (flag) => { const { flagsReady } = useFlagsStatus(); const isFlagEnabled = useFlag(flag); - return flagsReady ? isFlagEnabled : false; + return flagsReady ? isFlagEnabled : undefined; }; export default useFeatureFlag; -export const useKesselFeatureFlag = () => - useFeatureFlag('patch-frontend.kessel-enabled'); +export const useKesselFeatureFlag = () => useFeatureFlag('patch-frontend.kessel-enabled'); diff --git a/src/Utilities/hooks/useKesselWorkspaces.js b/src/Utilities/hooks/useKesselWorkspaces.js new file mode 100644 index 000000000..7837e1e76 --- /dev/null +++ b/src/Utilities/hooks/useKesselWorkspaces.js @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; +import { RBAC_API_BASE_V2 } from '../constants'; + +const STALE_TIME = 5 * 60 * 1000; + +export const useKesselWorkspaces = (options = {}) => + useQuery({ + queryKey: ['workspaces', options.type, options.limit], + queryFn: async () => { + const response = await fetch( + `${RBAC_API_BASE_V2}/workspaces/?limit=${options.limit ?? 1000}&type=${options.type ?? 'all'}`, + ); + if (!response.ok) { + throw new Error('Failed to fetch workspaces'); + } + const data = await response.json(); + return data.data || []; + }, + enabled: options.enabled ?? true, + staleTime: options.staleTime, + }); + +export const useFetchDefaultWorkspaceId = (enabled = true) => { + const { + data: workspaces, + isLoading, + error, + } = useKesselWorkspaces({ type: 'default', limit: 1, staleTime: STALE_TIME, enabled }); + const defaultWorkspace = workspaces?.[0]; + + return { + workspaceId: defaultWorkspace?.id, + isLoading: enabled ? isLoading : false, + error, + }; +}; diff --git a/src/Utilities/hooks/usePermissionCheck.js b/src/Utilities/hooks/usePermissionCheck.js new file mode 100644 index 000000000..29d79af96 --- /dev/null +++ b/src/Utilities/hooks/usePermissionCheck.js @@ -0,0 +1,81 @@ +import { useMemo } from 'react'; +import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook'; +import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check'; +import { useFetchDefaultWorkspaceId } from './useKesselWorkspaces'; +import useFeatureFlag from './useFeatureFlag'; +import { featureFlags } from '../constants'; + +export const PERMISSION_MAP = { + 'patch:*:read': 'patch_system_view', + 'patch:*:*': 'patch_system_edit', + 'patch:template:write': 'patch_template_edit', +}; + +const getKesselAccessCheckParams = (permissionMap, requiredPermissions, workspaceId) => { + const resources = requiredPermissions + .map((perm) => { + const relation = permissionMap[perm]; + if (!relation) { + return null; + } + return { id: workspaceId, type: 'workspace', relation }; + }) + .filter(Boolean); + + if (resources.length === 0) { + return { resources: [] }; + } + + if (resources.length === 1) { + return { + resource: { id: resources[0].id, type: resources[0].type }, + relation: resources[0].relation, + }; + } + + return { resources }; +}; + +export const useRbacV1Permissions = (requiredPermissions) => { + const { hasAccess, isLoading } = usePermissionsWithContext(requiredPermissions); + return { hasAccess, isLoading }; +}; + +export const useKesselPermissions = (requiredPermissions, enabled = true) => { + const { workspaceId, isLoading: workspaceLoading } = useFetchDefaultWorkspaceId(enabled); + + const checkParams = useMemo( + () => getKesselAccessCheckParams(PERMISSION_MAP, requiredPermissions, workspaceId), + [workspaceId, requiredPermissions], + ); + + const { data, loading, error } = useSelfAccessCheck(checkParams); + + if (workspaceLoading) { + return { hasAccess: false, isLoading: true }; + } + + if (!workspaceId || error) { + return { hasAccess: false, isLoading: false }; + } + + const hasAccess = Array.isArray(data) + ? data.some((check) => check.allowed) + : (data?.allowed ?? false); + + return { hasAccess, isLoading: loading }; +}; + +const usePermissionCheck = (requiredPermissions) => { + const isKesselEnabled = useFeatureFlag(featureFlags.kessel_enabled); + const rbac = useRbacV1Permissions(requiredPermissions); + const kessel = useKesselPermissions(requiredPermissions, !!isKesselEnabled); + + if (isKesselEnabled === undefined) { + return { hasAccess: false, isLoading: true }; + } + + return isKesselEnabled ? kessel : rbac; +}; + +export default usePermissionCheck;