diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Permissions/DomainViewPermissions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Permissions/DomainViewPermissions.spec.ts new file mode 100644 index 000000000000..671dbd216a3c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Permissions/DomainViewPermissions.spec.ts @@ -0,0 +1,173 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, Page, test as base } from '@playwright/test'; +import { PolicyClass } from '../../../support/access-control/PoliciesClass'; +import { RolesClass } from '../../../support/access-control/RolesClass'; +import { DataProduct } from '../../../support/domain/DataProduct'; +import { Domain } from '../../../support/domain/Domain'; +import { UserClass } from '../../../support/user/UserClass'; +import { performAdminLogin } from '../../../utils/admin'; +import { redirectToHomePage } from '../../../utils/common'; + +const adminUser = new UserClass(); +const testUser = new UserClass(); +const domain = new Domain(); +const dataProduct = new DataProduct([domain]); + +let policy: PolicyClass; +let role: RolesClass; + +const test = base.extend<{ + page: Page; + testUserPage: Page; +}>({ + page: async ({ browser }, use) => { + const adminPage = await browser.newPage(); + try { + await adminUser.login(adminPage); + await use(adminPage); + } finally { + await adminPage.close(); + } + }, + testUserPage: async ({ browser }, use) => { + const userPage = await browser.newPage(); + try { + await testUser.login(userPage); + await use(userPage); + } finally { + await userPage.close(); + } + }, +}); + +test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await adminUser.create(apiContext); + await adminUser.setAdminRole(apiContext); + await testUser.create(apiContext); + await domain.create(apiContext); + await dataProduct.create(apiContext); + + policy = new PolicyClass(); + await policy.create(apiContext, [ + { + name: 'DenyViewDomainRule', + resources: ['domain'], + operations: ['ViewAll', 'ViewBasic'], + effect: 'deny', + }, + { + name: 'DenyViewDataProductRule', + resources: ['dataProduct'], + operations: ['ViewAll', 'ViewBasic'], + effect: 'deny', + }, + ]); + + role = new RolesClass(); + await role.create(apiContext, [policy.responseData.name]); + + await testUser.patch({ + apiContext, + patchData: [ + { + op: 'replace', + path: '/roles', + value: [ + { + id: role.responseData.id, + type: 'role', + name: role.responseData.name, + }, + ], + }, + ], + }); + + await afterAction(); +}); + +test.describe('Domain and Data Product View Permission Denied', () => { + test('Domain listing page should show permission error', async ({ + testUserPage, + }) => { + await redirectToHomePage(testUserPage); + await testUserPage.goto('/domain'); + await testUserPage.waitForLoadState('networkidle'); + + await expect( + testUserPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + }); + + test('Data Product listing page should show permission error', async ({ + testUserPage, + }) => { + await redirectToHomePage(testUserPage); + await testUserPage.goto('/dataProduct'); + await testUserPage.waitForLoadState('networkidle'); + + await expect( + testUserPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + }); + + test('Domain detail page should show permission error', async ({ + testUserPage, + }) => { + const domainFqn = encodeURIComponent( + domain.responseData.fullyQualifiedName ?? domain.data.name + ); + await redirectToHomePage(testUserPage); + await testUserPage.goto(`/domain/${domainFqn}`); + await testUserPage.waitForLoadState('networkidle'); + + await expect( + testUserPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + }); + + test('Data Product detail page should show permission error', async ({ + testUserPage, + }) => { + const dataProductFqn = encodeURIComponent( + dataProduct.responseData.fullyQualifiedName ?? dataProduct.data.name + ); + await redirectToHomePage(testUserPage); + await testUserPage.goto(`/dataProduct/${dataProductFqn}`); + await testUserPage.waitForLoadState('networkidle'); + + await expect( + testUserPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + }); +}); + +test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + if (role?.responseData?.id) { + await role.delete(apiContext); + } + if (policy?.responseData?.id) { + await policy.delete(apiContext); + } + + await dataProduct.delete(apiContext); + await domain.delete(apiContext); + await adminUser.delete(apiContext); + await testUser.delete(apiContext); + await afterAction(); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx index dc5c4c49f78d..d325f101099b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx @@ -320,6 +320,19 @@ const AuthenticatedAppRouter: FunctionComponent = () => { checkPermission(Operation.Create, ResourceEntity.BOT, permissions), [permissions] ); + const domainViewPermission = useMemo( + () => + userPermissions.hasViewPermissions(ResourceEntity.DOMAIN, permissions), + [permissions] + ); + const dataProductViewPermission = useMemo( + () => + userPermissions.hasViewPermissions( + ResourceEntity.DATA_PRODUCT, + permissions + ), + [permissions] + ); return ( @@ -750,9 +763,20 @@ const AuthenticatedAppRouter: FunctionComponent = () => { } path="/glossary/*" /> } path="/glossary-term/*" /> } path="/settings/*" /> - } path="/domain/*" /> } + element={ + + + + } + path="/domain/*" + /> + + + + } path={ROUTES.DATA_PRODUCT} /> } path={ROUTES.METRICS} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx index e994f9698a32..4d4a463b3a31 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx @@ -14,6 +14,7 @@ import { render, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; +import { ENTITY_PERMISSIONS } from '../../../mocks/Permissions.mock'; import PageLayoutV1 from '../../PageLayoutV1/PageLayoutV1'; import DataProductsPage from './DataProductsPage.component'; @@ -111,6 +112,14 @@ jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => { return jest.fn().mockImplementation(({ children }) =>
{children}
); }); +jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: () => ({ + permissions: { + dataProduct: ENTITY_PERMISSIONS, + }, + }), +})); + describe('DataProductsPage component', () => { it('should render successfully', async () => { const { container } = render(, { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx index fe168bbcaa73..19655d64b251 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx @@ -19,9 +19,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '../../../constants/constants'; -import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; +import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; +import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { ClientErrors } from '../../../enums/Axios.enum'; +import { ERROR_PLACEHOLDER_TYPE, SIZE } from '../../../enums/common.enum'; import { EntityType, TabSpecificField } from '../../../enums/entity.enum'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; +import { Operation } from '../../../generated/entity/policies/policy'; import { EntityHistory } from '../../../generated/type/entityHistory'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useFqn } from '../../../hooks/useFqn'; @@ -35,6 +39,7 @@ import { removeFollower, } from '../../../rest/dataProductAPI'; import { getEntityName } from '../../../utils/EntityUtils'; +import { checkPermission } from '../../../utils/PermissionsUtils'; import { getDomainPath, getEntityDetailsPath, @@ -55,8 +60,25 @@ const DataProductsPage = () => { const { currentUser } = useApplicationStore(); const currentUserId = currentUser?.id ?? ''; const { fqn: dataProductFqn } = useFqn(); + const { permissions } = usePermissionProvider(); const [isMainContentLoading, setIsMainContentLoading] = useState(true); const [dataProduct, setDataProduct] = useState(); + const [isForbidden, setIsForbidden] = useState(false); + + const [viewBasicPermission, viewAllPermission] = useMemo(() => { + return [ + checkPermission( + Operation.ViewBasic, + ResourceEntity.DATA_PRODUCT, + permissions + ), + checkPermission( + Operation.ViewAll, + ResourceEntity.DATA_PRODUCT, + permissions + ), + ]; + }, [permissions]); const [versionList, setVersionList] = useState( {} as EntityHistory ); @@ -120,6 +142,7 @@ const DataProductsPage = () => { const fetchDataProductByFqn = async (fqn: string) => { setIsMainContentLoading(true); + setIsForbidden(false); try { const data = await getDataProductByName(fqn, { fields: [ @@ -140,7 +163,11 @@ const DataProductsPage = () => { fetchActiveVersion(data); } } catch (error) { - showErrorToast(error as AxiosError); + if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { + setIsForbidden(true); + } else { + showErrorToast(error as AxiosError); + } } finally { setIsMainContentLoading(false); } @@ -277,10 +304,36 @@ const DataProductsPage = () => { } }, [dataProductFqn, version]); + if (!(viewBasicPermission || viewAllPermission)) { + return ( + + ); + } + if (isMainContentLoading) { return ; } + if (isForbidden) { + return ( + + ); + } + if (!dataProduct) { return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx index ad181a5c854d..4c76a4dd46ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx @@ -19,6 +19,7 @@ import { useNavigate } from 'react-router-dom'; import { ROUTES } from '../../../constants/constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { ClientErrors } from '../../../enums/Axios.enum'; import { ERROR_PLACEHOLDER_TYPE, SIZE } from '../../../enums/common.enum'; import { TabSpecificField } from '../../../enums/entity.enum'; import { Domain } from '../../../generated/entity/domains/domain'; @@ -52,6 +53,7 @@ const DomainDetailPage = () => { const { updateDomains } = useDomainStore(); const [isMainContentLoading, setIsMainContentLoading] = useState(false); const [activeDomain, setActiveDomain] = useState(); + const [isForbidden, setIsForbidden] = useState(false); const [isFollowingLoading, setIsFollowingLoading] = useState(false); const { isFollowing } = useMemo(() => { @@ -96,6 +98,7 @@ const DomainDetailPage = () => { const fetchDomainByName = async (domainFqn: string) => { setIsMainContentLoading(true); + setIsForbidden(false); try { const data = await getDomainByName(domainFqn, { fields: [ @@ -110,12 +113,16 @@ const DomainDetailPage = () => { }); setActiveDomain(data); } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-fetch-error', { - entity: t('label.domain-lowercase'), - }) - ); + if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { + setIsForbidden(true); + } else { + showErrorToast( + error as AxiosError, + t('server.entity-fetch-error', { + entity: t('label.domain-lowercase'), + }) + ); + } } finally { setIsMainContentLoading(false); } @@ -210,6 +217,19 @@ const DomainDetailPage = () => { return ; } + if (isForbidden) { + return ( + + ); + } + if (!activeDomain) { return ; }