From 7877d5c14c924b560121c71aa702daea1b9dc69c Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Thu, 28 Nov 2024 16:15:07 +0530 Subject: [PATCH] Allow Asset add and remove operation for editTag permission user (#18786) * Allow Asset add and remove operation for editTag permission user * remove edit all permission check in bulkAssetTag api for tag * fix the permission check by checking specific and the index used to exclude * check editTag permission before updating assets by bulkTagAsset api * fix the button not visible for the non admin user * added playwright for the limited user check aroud asset * restrict add asset button in certification page * minor fix * cleanup aroud tag page and fix usertab count around Team page * use entity type instead of search index --------- Co-authored-by: sonikashah Co-authored-by: karanh37 --- .../exception/CatalogExceptionMessage.java | 7 + .../service/resources/EntityResource.java | 71 +++++- .../ui/playwright/e2e/Pages/Tag.spec.ts | 147 +++++++++++-- .../main/resources/ui/playwright/utils/tag.ts | 156 ++++++++++++- .../Team/TeamDetails/TeamDetailsV1.utils.tsx | 2 +- .../TeamDetails/UserTab/UserTab.component.tsx | 5 +- .../ExplorePage/ExplorePage.interface.ts | 2 +- .../ui/src/pages/TagPage/TagPage.tsx | 60 +++-- .../ui/src/utils/PermissionsUtils.ts | 25 +++ .../main/resources/ui/src/utils/TagsUtils.tsx | 208 +++++++++++++++++- 10 files changed, 616 insertions(+), 67 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java index 6371d754a39..7935488bd8f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java @@ -220,6 +220,13 @@ public final class CatalogExceptionMessage { "Principal: CatalogPrincipal{name='%s'} operations %s not allowed", user, operations); } + public static String resourcePermissionNotAllowed( + String user, List operations, List resources) { + return String.format( + "Principal: CatalogPrincipal{name='%s'} operations %s not allowed for resources {%s}.", + user, operations, resources); + } + public static String domainPermissionNotAllowed( String user, String domainName, List operations) { return String.format( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java index 5606685d22c..d2044d44420 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java @@ -19,6 +19,7 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.schema.type.EventType.ENTITY_CREATED; import static org.openmetadata.schema.type.MetadataOperation.CREATE; import static org.openmetadata.schema.type.MetadataOperation.VIEW_BASIC; +import static org.openmetadata.service.security.DefaultAuthorizer.getSubjectContext; import static org.openmetadata.service.util.EntityUtil.createOrUpdateOperation; import java.io.IOException; @@ -29,6 +30,7 @@ import java.util.Set; import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; import javax.json.JsonPatch; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -42,6 +44,8 @@ import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.Permission; +import org.openmetadata.schema.type.ResourcePermission; import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.schema.type.csv.CsvImportResult; import org.openmetadata.service.Entity; @@ -52,11 +56,13 @@ import org.openmetadata.service.jdbi3.ListFilter; import org.openmetadata.service.limits.Limits; import org.openmetadata.service.search.SearchListFilter; import org.openmetadata.service.search.SearchSortFilter; +import org.openmetadata.service.security.AuthorizationException; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.CreateResourceContext; import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.security.policyevaluator.ResourceContext; import org.openmetadata.service.security.policyevaluator.ResourceContextInterface; +import org.openmetadata.service.security.policyevaluator.SubjectContext; import org.openmetadata.service.util.AsyncService; import org.openmetadata.service.util.BulkAssetsOperationResponse; import org.openmetadata.service.util.CSVExportResponse; @@ -418,9 +424,37 @@ public abstract class EntityResource editPermissibleResources = + authorizer.listPermissions(securityContext, user).stream() + .filter( + permission -> + permission.getPermissions().stream() + .anyMatch( + perm -> + MetadataOperation.EDIT_TAGS.equals(perm.getOperation()) + && Permission.Access.ALLOW.equals(perm.getAccess()))) + .map(ResourcePermission::getResource) + .collect(Collectors.toSet()); + + // Validate if all entity types in the request are in the permissible resources + List unauthorizedEntityTypes = + request.getAssets().stream() + .map(EntityReference::getType) + .filter(entityType -> !editPermissibleResources.contains(entityType)) + .distinct() + .toList(); + + if (!unauthorizedEntityTypes.isEmpty() + && !subjectContext.isAdmin() + && !subjectContext.isBot()) { + throw new AuthorizationException( + CatalogExceptionMessage.resourcePermissionNotAllowed( + user, List.of(MetadataOperation.EDIT_TAGS), unauthorizedEntityTypes)); + } + String jobId = UUID.randomUUID().toString(); ExecutorService executorService = AsyncService.getInstance().getExecutorService(); executorService.submit( @@ -443,9 +477,34 @@ public abstract class EntityResource editPermissibleResources = + authorizer.listPermissions(securityContext, user).stream() + .filter( + permission -> + permission.getPermissions().stream() + .anyMatch( + perm -> + MetadataOperation.EDIT_TAGS.equals(perm.getOperation()) + && Permission.Access.ALLOW.equals(perm.getAccess()))) + .map(ResourcePermission::getResource) + .collect(Collectors.toSet()); + + List unauthorizedEntityTypes = + request.getAssets().stream() + .map(EntityReference::getType) + .filter(entityType -> !editPermissibleResources.contains(entityType)) + .distinct() + .toList(); + + if (!unauthorizedEntityTypes.isEmpty() + && !subjectContext.isAdmin() + && !subjectContext.isBot()) { + throw new AuthorizationException( + CatalogExceptionMessage.resourcePermissionNotAllowed( + user, List.of(MetadataOperation.EDIT_TAGS), unauthorizedEntityTypes)); + } String jobId = UUID.randomUUID().toString(); ExecutorService executorService = AsyncService.getInstance().getExecutorService(); executorService.submit( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts index 90c8189fff5..8ef44d871d3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts @@ -11,28 +11,29 @@ * limitations under the License. */ import { expect, Page, test as base } from '@playwright/test'; -import { DATA_STEWARD_RULES } from '../../constant/permission'; import { PolicyClass } from '../../support/access-control/PoliciesClass'; import { RolesClass } from '../../support/access-control/RolesClass'; import { ClassificationClass } from '../../support/tag/ClassificationClass'; import { TagClass } from '../../support/tag/TagClass'; +import { TeamClass } from '../../support/team/TeamClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { redirectToHomePage } from '../../utils/common'; +import { getApiContext, redirectToHomePage, uuid } from '../../utils/common'; import { addAssetsToTag, - checkAssetsCount, editTagPageDescription, + LIMITED_USER_RULES, removeAssetsFromTag, setupAssetsForTag, + verifyCertificationTagPageUI, verifyTagPageUI, } from '../../utils/tag'; const adminUser = new UserClass(); const dataConsumerUser = new UserClass(); const dataStewardUser = new UserClass(); -const policy = new PolicyClass(); -const role = new RolesClass(); +const limitedAccessUser = new UserClass(); + const classification = new ClassificationClass({ provider: 'system', mutuallyExclusive: true, @@ -45,6 +46,7 @@ const test = base.extend<{ adminPage: Page; dataConsumerPage: Page; dataStewardPage: Page; + limitedAccessPage: Page; }>({ adminPage: async ({ browser }, use) => { const adminPage = await browser.newPage(); @@ -64,6 +66,12 @@ const test = base.extend<{ await use(page); await page.close(); }, + limitedAccessPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await limitedAccessUser.login(page); + await use(page); + await page.close(); + }, }); base.beforeAll('Setup pre-requests', async ({ browser }) => { @@ -73,8 +81,7 @@ base.beforeAll('Setup pre-requests', async ({ browser }) => { await dataConsumerUser.create(apiContext); await dataStewardUser.create(apiContext); await dataStewardUser.setDataStewardRole(apiContext); - await policy.create(apiContext, DATA_STEWARD_RULES); - await role.create(apiContext, [policy.responseData.name]); + await limitedAccessUser.create(apiContext); await classification.create(apiContext); await tag.create(apiContext); await afterAction(); @@ -85,8 +92,7 @@ base.afterAll('Cleanup', async ({ browser }) => { await adminUser.delete(apiContext); await dataConsumerUser.delete(apiContext); await dataStewardUser.delete(apiContext); - await policy.delete(apiContext); - await role.delete(apiContext); + await limitedAccessUser.delete(apiContext); await classification.delete(apiContext); await tag.delete(apiContext); await afterAction(); @@ -99,6 +105,12 @@ test.describe('Tag Page with Admin Roles', () => { await verifyTagPageUI(adminPage, classification.data.name, tag); }); + test('Certification Page should not have Asset button', async ({ + adminPage, + }) => { + await verifyCertificationTagPageUI(adminPage); + }); + test('Rename Tag name', async ({ adminPage }) => { await redirectToHomePage(adminPage); const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); @@ -202,22 +214,15 @@ test.describe('Tag Page with Admin Roles', () => { test('Add and Remove Assets', async ({ adminPage }) => { await redirectToHomePage(adminPage); - const { assets } = await setupAssetsForTag(adminPage); + const { assets, assetCleanup } = await setupAssetsForTag(adminPage); - await test.step('Add Asset', async () => { - const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(adminPage); - await res; - await addAssetsToTag(adminPage, assets); + await test.step('Add Asset ', async () => { + await addAssetsToTag(adminPage, assets, tag); }); await test.step('Delete Asset', async () => { - const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(adminPage); - await res; - - await removeAssetsFromTag(adminPage, assets); - await checkAssetsCount(adminPage, 0); + await removeAssetsFromTag(adminPage, assets, tag); + await assetCleanup(); }); }); }); @@ -234,11 +239,34 @@ test.describe('Tag Page with Data Consumer Roles', () => { ); }); - test('Edit Tag Description or Data Consumer', async ({ + test('Certification Page should not have Asset button for Data Consumer', async ({ + dataConsumerPage, + }) => { + await verifyCertificationTagPageUI(dataConsumerPage); + }); + + test('Edit Tag Description for Data Consumer', async ({ dataConsumerPage, }) => { await editTagPageDescription(dataConsumerPage, tag); }); + + test('Add and Remove Assets for Data Consumer', async ({ + adminPage, + dataConsumerPage, + }) => { + const { assets, assetCleanup } = await setupAssetsForTag(adminPage); + await redirectToHomePage(dataConsumerPage); + + await test.step('Add Asset ', async () => { + await addAssetsToTag(dataConsumerPage, assets, tag); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(dataConsumerPage, assets, tag); + await assetCleanup(); + }); + }); }); test.describe('Tag Page with Data Steward Roles', () => { @@ -248,7 +276,82 @@ test.describe('Tag Page with Data Steward Roles', () => { await verifyTagPageUI(dataStewardPage, classification.data.name, tag, true); }); + test('Certification Page should not have Asset button for Data Steward', async ({ + dataStewardPage, + }) => { + await verifyCertificationTagPageUI(dataStewardPage); + }); + test('Edit Tag Description for Data Steward', async ({ dataStewardPage }) => { await editTagPageDescription(dataStewardPage, tag); }); + + test('Add and Remove Assets for Data Steward', async ({ + adminPage, + dataStewardPage, + }) => { + const { assets, assetCleanup } = await setupAssetsForTag(adminPage); + await redirectToHomePage(dataStewardPage); + + await test.step('Add Asset ', async () => { + await addAssetsToTag(dataStewardPage, assets, tag); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(dataStewardPage, assets, tag); + await assetCleanup(); + }); + }); +}); + +test.describe('Tag Page with Limited EditTag Permission', () => { + test.slow(true); + + test('Add and Remove Assets and Check Restricted Entity', async ({ + adminPage, + limitedAccessPage, + }) => { + const { apiContext, afterAction } = await getApiContext(adminPage); + const { assets, otherAsset, assetCleanup } = await setupAssetsForTag( + adminPage + ); + const id = uuid(); + const policy = new PolicyClass(); + const role = new RolesClass(); + let limitedAccessTeam: TeamClass | null = null; + + try { + await policy.create(apiContext, LIMITED_USER_RULES); + await role.create(apiContext, [policy.responseData.name]); + + limitedAccessTeam = new TeamClass({ + name: `PW%limited_user_access_team-${id}`, + displayName: `PW Limited User Access Team ${id}`, + description: 'playwright data steward team description', + teamType: 'Group', + users: [limitedAccessUser.responseData.id], + defaultRoles: role.responseData.id ? [role.responseData.id] : [], + }); + await limitedAccessTeam.create(apiContext); + + await redirectToHomePage(limitedAccessPage); + + await test.step('Add Asset ', async () => { + await addAssetsToTag(limitedAccessPage, assets, tag, otherAsset); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(limitedAccessPage, assets, tag); + }); + } finally { + await tag.delete(apiContext); + await policy.delete(apiContext); + await role.delete(apiContext); + if (limitedAccessTeam) { + await limitedAccessTeam.delete(apiContext); + } + await assetCleanup(); + await afterAction(); + } + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts index 536542f315a..6619cbc2003 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -11,10 +11,13 @@ * limitations under the License. */ import { expect, Page } from '@playwright/test'; -import { get } from 'lodash'; +import { get, isUndefined } from 'lodash'; import { SidebarItem } from '../constant/sidebar'; +import { PolicyRulesType } from '../support/access-control/PoliciesClass'; import { DashboardClass } from '../support/entity/DashboardClass'; import { EntityClass } from '../support/entity/EntityClass'; +import { MlModelClass } from '../support/entity/MlModelClass'; +import { PipelineClass } from '../support/entity/PipelineClass'; import { TableClass } from '../support/entity/TableClass'; import { TopicClass } from '../support/entity/TopicClass'; import { TagClass } from '../support/tag/TagClass'; @@ -51,20 +54,54 @@ export const visitClassificationPage = async ( await expect(page.locator('.activeCategory')).toContainText( classificationName ); + + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); }; -export const addAssetsToTag = async (page: Page, assets: EntityClass[]) => { +// Other asset type that should not get from the search in explore, they are not added to the tag +export const addAssetsToTag = async ( + page: Page, + assets: EntityClass[], + tag: TagClass, + otherAsset?: EntityClass[] +) => { + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('assets').click(); + const initialFetchResponse = page.waitForResponse( + '/api/v1/search/query?q=&index=all&from=0&size=25&deleted=false**' + ); await page.getByTestId('data-classification-add-button').click(); + await initialFetchResponse; + await expect(page.getByRole('dialog')).toBeVisible(); + if (!isUndefined(otherAsset)) { + for (const asset of otherAsset) { + const name = get(asset, 'entityResponseData.name'); + + const searchRes = page.waitForResponse( + `/api/v1/search/query?q=${name}&index=all&from=0&size=25&**` + ); + await page + .getByTestId('asset-selection-modal') + .getByTestId('searchbar') + .fill(name); + await searchRes; + + await expect(page.getByText(name)).not.toBeVisible(); + } + } + for (const asset of assets) { const name = get(asset, 'entityResponseData.name'); const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); const searchRes = page.waitForResponse( - `/api/v1/search/query?q=${name}&index=all&from=0&size=25&*` + `/api/v1/search/query?q=${name}&index=all&from=0&size=25&**` ); await page .getByTestId('asset-selection-modal') @@ -82,8 +119,13 @@ export const addAssetsToTag = async (page: Page, assets: EntityClass[]) => { export const removeAssetsFromTag = async ( page: Page, - assets: EntityClass[] + assets: EntityClass[], + tag: TagClass ) => { + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('assets').click(); for (const asset of assets) { const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); @@ -94,6 +136,8 @@ export const removeAssetsFromTag = async ( await page.getByTestId('delete-all-button').click(); await assetsRemoveRes; + + await checkAssetsCount(page, 0); }; export const checkAssetsCount = async (page: Page, count: number) => { @@ -107,10 +151,14 @@ export const setupAssetsForTag = async (page: Page) => { const table = new TableClass(); const topic = new TopicClass(); const dashboard = new DashboardClass(); + const mlModel = new MlModelClass(); + const pipeline = new PipelineClass(); await Promise.all([ table.create(apiContext), topic.create(apiContext), dashboard.create(apiContext), + mlModel.create(apiContext), + pipeline.create(apiContext), ]); const assetCleanup = async () => { @@ -118,12 +166,15 @@ export const setupAssetsForTag = async (page: Page) => { table.delete(apiContext), topic.delete(apiContext), dashboard.delete(apiContext), + mlModel.delete(apiContext), + pipeline.delete(apiContext), ]); await afterAction(); }; return { assets: [table, topic, dashboard], + otherAsset: [mlModel, pipeline], assetCleanup, }; }; @@ -246,17 +297,13 @@ export const verifyTagPageUI = async ( ); await expect(page.getByText(tag.data.description)).toBeVisible(); + await expect( + page.getByTestId('data-classification-add-button') + ).toBeVisible(); + if (limitedAccess) { - await expect( - page.getByTestId('data-classification-add-button') - ).not.toBeVisible(); await expect(page.getByTestId('manage-button')).not.toBeVisible(); await expect(page.getByTestId('add-domain')).not.toBeVisible(); - - // Asset tab should show no data placeholder and not add asset button - await page.getByTestId('assets').click(); - - await expect(page.getByTestId('no-data-placeholder')).toBeVisible(); } const classificationTable = page.waitForResponse( @@ -295,3 +342,88 @@ export const editTagPageDescription = async (page: Page, tag: TagClass) => { `This is updated test description for tag ${tag.data.name}.` ); }; + +export const verifyCertificationTagPageUI = async (page: Page) => { + await redirectToHomePage(page); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await visitClassificationPage(page, 'Certification'); + await page.getByTestId('Gold').click(); + await res; + + await page.getByTestId('assets').click(); + + await expect( + page.getByTestId('data-classification-add-button') + ).not.toBeVisible(); + await expect(page.getByTestId('no-data-placeholder')).toBeVisible(); +}; + +export const LIMITED_USER_RULES: PolicyRulesType[] = [ + { + name: 'limitedUserEditTagRole', + resources: [ + 'apiCollection', + 'apiEndpoint', + 'apiService', + 'app', + 'appMarketPlaceDefinition', + 'bot', + 'chart', + 'classification', + 'container', + 'dashboardDataModel', + 'dashboardService', + 'database', + 'databaseSchema', + 'databaseService', + 'dataInsightChart', + 'dataInsightCustomChart', + 'dataInsightDashboard', + 'dataProduct', + 'document', + 'domain', + 'entityReportData', + 'eventsubscription', + 'feed', + 'glossary', + 'glossaryTerm', + 'ingestionPipeline', + 'kpi', + 'messagingService', + 'metadataService', + 'metric', + 'mlmodel', + 'mlmodelService', + 'page', + 'persona', + 'pipeline', + 'pipelineService', + 'policy', + 'query', + 'report', + 'role', + 'searchIndex', + 'searchService', + 'storageService', + 'storedProcedure', + 'suggestion', + 'tag', + 'team', + 'testCase', + 'testCaseResolutionStatus', + 'testCaseResult', + 'testConnectionDefinition', + 'testDefinition', + 'testSuite', + 'type', + 'user', + 'webAnalyticEvent', + 'workflow', + 'workflowDefinition', + 'workflowInstance', + 'workflowInstanceState', + ], + operations: ['EditTags'], + effect: 'deny', + }, +]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.utils.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.utils.tsx index 64b53ff438f..cfb8ccf31ed 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.utils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.utils.tsx @@ -30,7 +30,7 @@ export const getTabs = ( }, users: { name: t('label.user-plural'), - count: currentTeam.userCount ?? 0, + count: currentTeam.users?.length ?? 0, key: TeamsPageTab.USERS, }, assets: { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx index c6424046652..6a14ba5647e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx @@ -94,13 +94,12 @@ export const UserTab = ({ showPagination, } = usePaging(PAGE_SIZE_MEDIUM); - const usersList = useMemo(() => { return users.map((item) => getEntityReferenceFromEntity(item, EntityType.USER) ); }, [users]); - + const isGroupType = useMemo( () => currentTeam.teamType === TeamType.Group, [currentTeam.teamType] @@ -379,7 +378,7 @@ export const UserTab = ({ onSearch={handleUsersSearchAction} /> - {!currentTeam.deleted && ( + {!currentTeam.deleted && isGroupType && ( {users.length > 0 && permission.EditAll && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts index e9699b65d26..0aa80eff92b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts @@ -31,7 +31,7 @@ export interface EsTermQuery { } export type EsTermsQuery = { - [property: string]: string; + [property: string]: string | string[]; }; export interface EsExistsQuery { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index 626a3e782eb..7497686769f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -23,7 +23,7 @@ import { import { ItemType } from 'antd/lib/menu/hooks/useItems'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEmpty, startsWith } from 'lodash'; import React, { useCallback, useEffect, @@ -92,7 +92,8 @@ import { getEncodedFqn, } from '../../utils/StringsUtils'; import { - getQueryFilterToExcludeTerms, + getExcludedIndexesBasedOnEntityTypeEditTagPermission, + getQueryFilterToExcludeTermsAndEntities, getTagAssetsQueryFilter, } from '../../utils/TagsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; @@ -104,7 +105,7 @@ const TagPage = () => { const { fqn: tagFqn } = useFqn(); const history = useHistory(); const { tab: activeTab = TagTabs.OVERVIEW } = useParams<{ tab?: string }>(); - const { getEntityPermission } = usePermissionProvider(); + const { permissions, getEntityPermission } = usePermissionProvider(); const [isLoading, setIsLoading] = useState(false); const [tagItem, setTagItem] = useState(); const [assetModalVisible, setAssetModalVisible] = useState(false); @@ -161,6 +162,23 @@ const TagPage = () => { return { editTagsPermission: false, editDescriptionPermission: false }; }, [tagPermissions, tagItem?.deleted]); + const editEntitiesTagPermission = useMemo( + () => getExcludedIndexesBasedOnEntityTypeEditTagPermission(permissions), + [permissions] + ); + + const haveAssetEditPermission = useMemo( + () => + editTagsPermission || + !isEmpty(editEntitiesTagPermission.entitiesHavingPermission), + [editTagsPermission, editEntitiesTagPermission.entitiesHavingPermission] + ); + + const isCertificationClassification = useMemo( + () => startsWith(tagFqn, 'Certification.'), + [tagFqn] + ); + const fetchCurrentTagPermission = async () => { if (!tagItem?.id) { return; @@ -477,7 +495,16 @@ const TagPage = () => { assetCount={assetCount} entityFqn={tagItem?.fullyQualifiedName ?? ''} isSummaryPanelOpen={Boolean(previewAsset)} - permissions={tagPermissions} + permissions={ + { + Create: + haveAssetEditPermission && + !isCertificationClassification, + EditAll: + haveAssetEditPermission && + !isCertificationClassification, + } as OperationPermission + } ref={assetTabRef} type={AssetsOfEntity.TAG} onAddAsset={() => setAssetModalVisible(true)} @@ -591,17 +618,19 @@ const TagPage = () => { titleColor={tagItem.style?.color ?? BLACK_COLOR} /> - {editTagsPermission && ( + {haveAssetEditPermission && (
- + {!isCertificationClassification && ( + + )} {manageButtonContent.length > 0 && ( { setAssetModalVisible(false)} onSave={handleAssetSave} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts index b9c3a7661fa..463706988fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/PermissionsUtils.ts @@ -52,6 +52,31 @@ export const checkPermission = ( return hasPermission; }; +/** + * + * @param operation operation like Edit, Delete + * @param resourceType Resource type like "bot", "table" + * @param permissions UIPermission + * @param checkEditAllPermission boolean to check EditALL permission as well + * @returns boolean - true/false + */ +export const checkPermissionEntityResource = ( + operation: Operation, + resourceType: ResourceEntity, + permissions: UIPermission, + checkEditAllPermission = false +) => { + const entityResource = permissions?.[resourceType]; + let hasPermission = entityResource && entityResource[operation]; + + if (checkEditAllPermission) { + hasPermission = + hasPermission || (entityResource && entityResource[Operation.EditAll]); + } + + return hasPermission; +}; + /** * * @param permission ResourcePermission diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx index 93dc8a9790c..aaa80afaef1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx @@ -24,6 +24,10 @@ import Loader from '../components/common/Loader/Loader'; import RichTextEditorPreviewer from '../components/common/RichTextEditor/RichTextEditorPreviewer'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { getExplorePath } from '../constants/constants'; +import { + ResourceEntity, + UIPermission, +} from '../context/PermissionProvider/PermissionProvider.interface'; import { SettledStatus } from '../enums/Axios.enum'; import { EntityType } from '../enums/entity.enum'; import { ExplorePageTabs } from '../enums/Explore.enum'; @@ -32,6 +36,7 @@ import { Classification } from '../generated/entity/classification/classificatio import { Tag } from '../generated/entity/classification/tag'; import { GlossaryTerm } from '../generated/entity/data/glossaryTerm'; import { Column } from '../generated/entity/data/table'; +import { Operation } from '../generated/entity/policies/policy'; import { Paging } from '../generated/type/paging'; import { LabelType, State, TagLabel } from '../generated/type/tagLabel'; import { searchQuery } from '../rest/searchAPI'; @@ -41,6 +46,7 @@ import { getTags, } from '../rest/tagAPI'; import { getQueryFilterToIncludeApprovedTerm } from './GlossaryUtils'; +import { checkPermissionEntityResource } from './PermissionsUtils'; import { getTagsWithoutTier } from './TableUtils'; export const getClassifications = async ( @@ -318,7 +324,10 @@ export const createTagObject = (tags: EntityTags[]) => { ); }; -export const getQueryFilterToExcludeTerms = (fqn: string) => ({ +export const getQueryFilterToExcludeTermsAndEntities = ( + fqn: string, + excludeEntityIndex: string[] = [] +) => ({ query: { bool: { must: [ @@ -337,13 +346,17 @@ export const getQueryFilterToExcludeTerms = (fqn: string) => ({ bool: { must_not: [ { - term: { - entityType: EntityType.TAG, - }, - }, - { - term: { - entityType: EntityType.DATA_PRODUCT, + terms: { + entityType: [ + EntityType.CLASSIFICATION, + EntityType.TEST_SUITE, + EntityType.TEST_CASE, + EntityType.TEST_CASE_RESOLUTION_STATUS, + EntityType.TEST_CASE_RESULT, + EntityType.TAG, + EntityType.DATA_PRODUCT, + ...excludeEntityIndex, + ], }, }, ], @@ -354,6 +367,185 @@ export const getQueryFilterToExcludeTerms = (fqn: string) => ({ }, }); +export const getExcludedIndexesBasedOnEntityTypeEditTagPermission = ( + permissions: UIPermission +) => { + const entityPermission = { + [EntityType.TABLE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.TABLE, + permissions, + true + ), + [EntityType.TOPIC]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.TOPIC, + permissions, + true + ), + [EntityType.DASHBOARD]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DASHBOARD, + permissions, + true + ), + [EntityType.MLMODEL]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.ML_MODEL, + permissions, + true + ), + [EntityType.PIPELINE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.PIPELINE, + permissions, + true + ), + [EntityType.CONTAINER]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.CONTAINER, + permissions, + true + ), + [EntityType.SEARCH_INDEX]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.SEARCH_INDEX, + permissions, + true + ), + [EntityType.API_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.API_SERVICE, + permissions, + true + ), + [EntityType.API_ENDPOINT]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.API_ENDPOINT, + permissions, + true + ), + [EntityType.API_COLLECTION]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.API_COLLECTION, + permissions, + true + ), + [EntityType.DASHBOARD_DATA_MODEL]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DASHBOARD_DATA_MODEL, + permissions, + true + ), + [EntityType.STORED_PROCEDURE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.STORED_PROCEDURE, + permissions, + true + ), + [EntityType.DATABASE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DATABASE, + permissions, + true + ), + [EntityType.DATABASE_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DATABASE_SERVICE, + permissions, + true + ), + [EntityType.DATABASE_SCHEMA]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DATABASE_SCHEMA, + permissions, + true + ), + [EntityType.MESSAGING_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.PIPELINE_SERVICE, + permissions, + true + ), + [EntityType.DASHBOARD_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DASHBOARD_SERVICE, + permissions, + true + ), + [EntityType.MLMODEL_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.ML_MODEL_SERVICE, + permissions, + true + ), + [EntityType.PIPELINE_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.PIPELINE_SERVICE, + permissions, + true + ), + [EntityType.STORAGE_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.STORAGE_SERVICE, + permissions, + true + ), + [EntityType.SEARCH_SERVICE]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.SEARCH_SERVICE, + permissions, + true + ), + [EntityType.GLOSSARY]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.GLOSSARY, + permissions, + true + ), + [EntityType.GLOSSARY_TERM]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.GLOSSARY_TERM, + permissions, + true + ), + [EntityType.DOMAIN]: checkPermissionEntityResource( + Operation.EditTags, + ResourceEntity.DOMAIN, + permissions, + true + ), + }; + + return (Object.keys(entityPermission) as EntityType[]).reduce( + ( + acc: { + entitiesHavingPermission: EntityType[]; + entitiesNotHavingPermission: EntityType[]; + }, + cv: EntityType + ) => { + const currentEntityPermission = + entityPermission[cv as keyof typeof entityPermission]; + if (currentEntityPermission) { + return { + ...acc, + entitiesHavingPermission: [...acc.entitiesHavingPermission, cv], + }; + } + + return { + ...acc, + entitiesNotHavingPermission: [...acc.entitiesNotHavingPermission, cv], + }; + }, + { + entitiesHavingPermission: [], + entitiesNotHavingPermission: [], + } + ); +}; + export const getTagAssetsQueryFilter = (fqn: string) => { if (fqn.includes('Tier.')) { return `(tier.tagFQN:"${fqn}")`;