diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CreateResourceContext.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CreateResourceContext.java index 57d81e9aec4..77d97e544f9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CreateResourceContext.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CreateResourceContext.java @@ -1,23 +1,28 @@ package org.openmetadata.service.security.policyevaluator; import java.util.Collections; +import java.util.HashSet; import java.util.List; import javax.validation.constraints.NotNull; import lombok.Getter; import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.schema.entity.data.GlossaryTerm; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.EntityRepository; -import org.openmetadata.service.util.EntityUtil; +import org.openmetadata.service.util.EntityUtil.Fields; /** * ResourceContext used for CREATE operations where ownership, tags are inherited from the parent term. * *

As multiple threads don't access this, the class is not thread-safe by design. */ +@Slf4j public class CreateResourceContext implements ResourceContextInterface { @NonNull @Getter private final String resource; private final EntityRepository entityRepository; @@ -54,26 +59,47 @@ public class CreateResourceContext implements Resourc } private void setParent(T entity) { - String fields = ""; + Fields fields = new Fields(new HashSet<>()); if (entityRepository.isSupportsOwners()) { - fields = EntityUtil.addField(fields, Entity.FIELD_OWNERS); + fields.getFieldList().add(Entity.FIELD_OWNERS); } if (entityRepository.isSupportsTags()) { - fields = EntityUtil.addField(fields, Entity.FIELD_TAGS); + fields.getFieldList().add(Entity.FIELD_TAGS); } if (entityRepository.isSupportsDomain()) { - fields = EntityUtil.addField(fields, Entity.FIELD_DOMAIN); + fields.getFieldList().add(Entity.FIELD_DOMAIN); } if (entityRepository.isSupportsReviewers()) { - fields = EntityUtil.addField(fields, Entity.FIELD_REVIEWERS); - } - if (entityRepository.isSupportsDomain()) { - fields = EntityUtil.addField(fields, Entity.FIELD_DOMAIN); + fields.getFieldList().add(Entity.FIELD_REVIEWERS); } try { - parentEntity = entityRepository.getParentEntity(entity, fields); + // First, check direct parent + parentEntity = entityRepository.getParentEntity(entity, fields.toString()); + // If direct parent is not found, check for root-level parent + if (parentEntity == null) { + parentEntity = resolveRootParentEntity(entity, fields); + } } catch (EntityNotFoundException e) { parentEntity = null; } } + + private EntityInterface resolveRootParentEntity(T entity, Fields fields) { + try { + EntityReference rootReference = + switch (entityRepository.getEntityType()) { + case Entity.GLOSSARY_TERM -> ((GlossaryTerm) entity).getGlossary(); + case Entity.TAG -> ((Tag) entity).getClassification(); + case Entity.DATA_PRODUCT -> entity.getDomain(); + default -> null; + }; + + if (rootReference == null || rootReference.getId() == null) return null; + EntityRepository rootRepository = Entity.getEntityRepository(rootReference.getType()); + return rootRepository.get(null, rootReference.getId(), fields); + } catch (Exception e) { + LOG.error("Failed to resolve root parent entity: {}", e.getMessage(), e); + return null; + } + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java index 499f379d014..c0cbb71b377 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/ResourceContext.java @@ -1,12 +1,19 @@ package org.openmetadata.service.security.policyevaluator; +import static org.openmetadata.service.Entity.FIELD_OWNERS; + import com.google.common.annotations.VisibleForTesting; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.UUID; import lombok.Getter; import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.schema.entity.data.GlossaryTerm; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.TagLabel; @@ -22,6 +29,7 @@ import org.openmetadata.service.util.EntityUtil.Fields; * *

As multiple threads don't access this, the class is not thread-safe by design. */ +@Slf4j public class ResourceContext implements ResourceContextInterface { @NonNull @Getter private final String resource; private final EntityRepository entityRepository; @@ -73,7 +81,36 @@ public class ResourceContext implements ResourceConte } else if (Entity.USER.equals(entityRepository.getEntityType())) { return List.of(entity.getEntityReference()); // Owner for a user is same as the user } - return entity.getOwners(); + + // Check for parent owners + List owners = entity.getOwners(); + EntityInterface parentEntity = resolveParentEntity(entity); + if (parentEntity != null && parentEntity.getOwners() != null) { + if (owners == null) owners = new ArrayList<>(); + owners.addAll(parentEntity.getOwners()); + } + + return owners; + } + + private EntityInterface resolveParentEntity(T entity) { + Fields fields = new Fields(new HashSet<>(Collections.singleton(FIELD_OWNERS))); + try { + EntityReference parentReference = + switch (entityRepository.getEntityType()) { + case Entity.GLOSSARY_TERM -> ((GlossaryTerm) entity).getGlossary(); + case Entity.TAG -> ((Tag) entity).getClassification(); + case Entity.DATA_PRODUCT -> entity.getDomain(); + default -> null; + }; + + if (parentReference == null || parentReference.getId() == null) return null; + EntityRepository rootRepository = Entity.getEntityRepository(parentReference.getType()); + return rootRepository.get(null, parentReference.getId(), fields); + } catch (Exception e) { + LOG.error("Failed to resolve parent entity: {}", e.getMessage(), e); + return null; + } } @Override diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java index 7f930c93fa9..94b1d84e30f 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java @@ -9,6 +9,9 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.openmetadata.common.utils.CommonUtil.listOf; import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.schema.type.MetadataOperation.CREATE; +import static org.openmetadata.schema.type.MetadataOperation.EDIT_TAGS; +import static org.openmetadata.service.resources.EntityResourceTest.DATA_CONSUMER_ROLE_NAME; import static org.openmetadata.service.security.policyevaluator.CompiledRule.parseExpression; import static org.openmetadata.service.security.policyevaluator.SubjectContext.TEAM_FIELDS; @@ -16,12 +19,20 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.ImmutablePair; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.mockito.stubbing.Answer; +import org.openmetadata.schema.entity.data.Database; +import org.openmetadata.schema.entity.data.DatabaseSchema; import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.domains.DataProduct; +import org.openmetadata.schema.entity.domains.Domain; +import org.openmetadata.schema.entity.policies.Policy; +import org.openmetadata.schema.entity.policies.accessControl.Rule; import org.openmetadata.schema.entity.teams.Role; import org.openmetadata.schema.entity.teams.Team; import org.openmetadata.schema.entity.teams.User; @@ -30,19 +41,35 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.DataProductRepository; +import org.openmetadata.service.jdbi3.DatabaseRepository; +import org.openmetadata.service.jdbi3.DatabaseSchemaRepository; +import org.openmetadata.service.jdbi3.DomainRepository; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.TableRepository; import org.openmetadata.service.jdbi3.TeamRepository; import org.openmetadata.service.security.policyevaluator.SubjectContext.PolicyContext; +import org.openmetadata.service.util.EntityUtil; import org.springframework.expression.EvaluationContext; -import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; +@Slf4j class RuleEvaluatorTest { private static final Table table = new Table().withName("table"); private static User user; private static EvaluationContext evaluationContext; private static SubjectContext subjectContext; private static ResourceContext resourceContext; + private static final String DATA_CONSUMER_POLICY_NAME = "DataConsumerPolicy"; + + private static CreateResourceContext createResourceContextSchema; + private static CreateResourceContext createResourceContextDataProduct; + private static ResourceContext resourceContextDataProduct; + + private static User ownerUser; + private static User nonOwnerUser; + private static EntityReference ownerRef; + private static EntityReference databaseRef; @BeforeAll public static void setup() { @@ -87,12 +114,77 @@ class RuleEvaluatorTest { Mockito.when(tableRepository.getAllTags(any())) .thenAnswer((Answer>) invocationOnMock -> table.getTags()); + DatabaseRepository databaseRepository = mock(DatabaseRepository.class); + Mockito.when(databaseRepository.getEntityType()).thenReturn(Entity.DATABASE); + Mockito.when(databaseRepository.isSupportsOwners()).thenReturn(Boolean.TRUE); + Entity.registerEntity(Database.class, Entity.DATABASE, databaseRepository); + + DatabaseSchemaRepository databaseSchemaRepository = mock(DatabaseSchemaRepository.class); + Mockito.when(databaseSchemaRepository.getEntityType()).thenReturn(Entity.DATABASE_SCHEMA); + Mockito.when(databaseSchemaRepository.isSupportsOwners()).thenReturn(Boolean.TRUE); + Entity.registerEntity(DatabaseSchema.class, Entity.DATABASE_SCHEMA, databaseSchemaRepository); + + DomainRepository domainRepository = mock(DomainRepository.class); + Mockito.when(domainRepository.getEntityType()).thenReturn(Entity.DOMAIN); + Mockito.when(domainRepository.isSupportsOwners()).thenReturn(Boolean.TRUE); + Entity.registerEntity(Domain.class, Entity.DOMAIN, domainRepository); + Mockito.when(domainRepository.get(isNull(), any(UUID.class), any(EntityUtil.Fields.class))) + .thenAnswer( + i -> + EntityRepository.CACHE_WITH_ID.get( + new ImmutablePair<>(Entity.DOMAIN, i.getArgument(1)))); + + DataProductRepository dataProductRepository = mock(DataProductRepository.class); + Mockito.when(dataProductRepository.getEntityType()).thenReturn(Entity.DATA_PRODUCT); + Mockito.when(dataProductRepository.isSupportsOwners()).thenReturn(Boolean.TRUE); + Entity.registerEntity(DataProduct.class, Entity.DATA_PRODUCT, dataProductRepository); + user = new User().withId(UUID.randomUUID()).withName("user"); + ownerUser = new User().withId(UUID.randomUUID()).withName("owner"); + nonOwnerUser = new User().withId(UUID.randomUUID()).withName("nonOwner"); + ownerRef = ownerUser.getEntityReference().withType(Entity.USER); + + Database database = new Database().withId(UUID.randomUUID()).withName("testDB"); + databaseRef = database.getEntityReference(); + DatabaseSchema schema = new DatabaseSchema().withId(UUID.randomUUID()).withName("testSchema"); + schema.setDatabase(databaseRef); + database.setOwners(List.of(ownerRef)); + EntityRepository.CACHE_WITH_ID.put( + new ImmutablePair<>(Entity.DATABASE_SCHEMA, schema.getId()), schema); + EntityRepository.CACHE_WITH_ID.put( + new ImmutablePair<>(Entity.DATABASE, database.getId()), database); + Mockito.when(databaseSchemaRepository.getParentEntity(any(DatabaseSchema.class), anyString())) + .thenAnswer( + i -> { + DatabaseSchema cachedSchema = i.getArgument(0); + EntityReference dbRef = cachedSchema.getDatabase(); + if (dbRef == null) return null; + Database db = + (Database) + EntityRepository.CACHE_WITH_ID.get( + new ImmutablePair<>(Entity.DATABASE, dbRef.getId())); + return db; + }); + createResourceContextSchema = + Mockito.spy(new CreateResourceContext<>(Entity.DATABASE_SCHEMA, schema)); + + Domain domain = new Domain().withId(UUID.randomUUID()).withName("testDomain"); + DataProduct dataProduct = + new DataProduct().withId(UUID.randomUUID()).withName("testDataProduct"); + dataProduct.setDomain(domain.getEntityReference()); + domain.setOwners(List.of(ownerRef)); + EntityRepository.CACHE_WITH_ID.put(new ImmutablePair<>(Entity.DOMAIN, domain.getId()), domain); + EntityRepository.CACHE_WITH_ID.put( + new ImmutablePair<>(Entity.DATA_PRODUCT, dataProduct.getId()), dataProduct); + resourceContextDataProduct = + Mockito.spy(new ResourceContext<>(Entity.DATA_PRODUCT, dataProduct, dataProductRepository)); + createResourceContextDataProduct = + Mockito.spy(new CreateResourceContext<>(Entity.DATA_PRODUCT, dataProduct)); + resourceContext = new ResourceContext<>("table", table, mock(TableRepository.class)); subjectContext = new SubjectContext(user); RuleEvaluator ruleEvaluator = new RuleEvaluator(null, subjectContext, resourceContext); - evaluationContext = - SimpleEvaluationContext.forReadOnlyDataBinding().withRootObject(ruleEvaluator).build(); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); } @Test @@ -144,6 +236,73 @@ class RuleEvaluatorTest { .withName(user.getName()))); assertTrue(evaluateExpression("noOwner() || isOwner()")); assertFalse(evaluateExpression("!noOwner() && !isOwner()")); + + // Verify that parent owner has the necessary permissions to create child entities - + // createResourceContext + Role dataConsumerRole = new Role().withId(UUID.randomUUID()).withName(DATA_CONSUMER_ROLE_NAME); + ownerUser.setRoles(List.of(dataConsumerRole.getEntityReference())); + Rule createRule = + new Rule() + .withResources(List.of("All")) + .withOperations(List.of(CREATE)) + .withCondition("isOwner()") + .withEffect(Rule.Effect.ALLOW); + Policy policy = new Policy().withName(DATA_CONSUMER_POLICY_NAME).withRules(List.of(createRule)); + List compiledRules = List.of(new CompiledRule(createRule)); + PolicyContext policyContext = + new PolicyContext( + Entity.USER, + ownerUser.getName(), + DATA_CONSUMER_ROLE_NAME, + policy.getName(), + compiledRules); + + subjectContext = new SubjectContext(ownerUser); + RuleEvaluator ruleEvaluator = + new RuleEvaluator(policyContext, subjectContext, createResourceContextSchema); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + assertTrue(evaluateExpression("isOwner()")); + + subjectContext = new SubjectContext(nonOwnerUser); + ruleEvaluator = new RuleEvaluator(policyContext, subjectContext, createResourceContextSchema); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + assertFalse(evaluateExpression("isOwner()")); + + // Verify that domain owner has the necessary permissions to create/edit its dataProduct - + // ResourceContext (edit related permissions) + Rule editRule = + new Rule() + .withResources(List.of("All")) + .withOperations(List.of(CREATE, EDIT_TAGS)) + .withCondition("isOwner()") + .withEffect(Rule.Effect.ALLOW); + policy = new Policy().withName(DATA_CONSUMER_POLICY_NAME).withRules(List.of(editRule)); + compiledRules = List.of(new CompiledRule(editRule)); + policyContext = + new PolicyContext( + Entity.USER, + ownerUser.getName(), + DATA_CONSUMER_ROLE_NAME, + policy.getName(), + compiledRules); + + subjectContext = new SubjectContext(ownerUser); + ruleEvaluator = new RuleEvaluator(policyContext, subjectContext, resourceContextDataProduct); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + assertTrue(evaluateExpression("isOwner()")); + ruleEvaluator = + new RuleEvaluator(policyContext, subjectContext, createResourceContextDataProduct); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + assertTrue(evaluateExpression("isOwner()")); + + subjectContext = new SubjectContext(nonOwnerUser); + ruleEvaluator = new RuleEvaluator(policyContext, subjectContext, resourceContextDataProduct); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + assertFalse(evaluateExpression("isOwner()")); + ruleEvaluator = + new RuleEvaluator(policyContext, subjectContext, createResourceContextDataProduct); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + assertFalse(evaluateExpression("isOwner()")); } @Test @@ -363,7 +522,14 @@ class RuleEvaluatorTest { private void updatePolicyContext(String team) { PolicyContext policyContext = new PolicyContext(Entity.TEAM, team, null, null, null); RuleEvaluator ruleEvaluator = new RuleEvaluator(policyContext, subjectContext, resourceContext); - evaluationContext = - SimpleEvaluationContext.forReadOnlyDataBinding().withRootObject(ruleEvaluator).build(); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + } + + @AfterEach + void resetContext() { + subjectContext = new SubjectContext(user); + RuleEvaluator ruleEvaluator = new RuleEvaluator(null, subjectContext, resourceContext); + evaluationContext = new StandardEvaluationContext(ruleEvaluator); + LOG.info("Context reset to default state after test completion."); } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts index bbebc01dc1b..ce14a841a93 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import base, { expect, Page } from '@playwright/test'; +import base, { APIRequestContext, expect, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { get } from 'lodash'; import { SidebarItem } from '../../constant/sidebar'; @@ -24,7 +24,11 @@ import { ClassificationClass } from '../../support/tag/ClassificationClass'; import { TagClass } from '../../support/tag/TagClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { getApiContext, redirectToHomePage } from '../../utils/common'; +import { + clickOutside, + getApiContext, + redirectToHomePage, +} from '../../utils/common'; import { CustomPropertyTypeByName } from '../../utils/customProperty'; import { addAssetsToDataProduct, @@ -36,15 +40,16 @@ import { createSubDomain, removeAssetsFromDataProduct, selectDataProduct, + selectDataProductFromTab, selectDomain, selectSubDomain, setupAssetsForDomain, + setupDomainOwnershipTest, verifyDataProductAssetsAfterDelete, verifyDomain, } from '../../utils/domain'; import { sidebarClick } from '../../utils/sidebar'; import { performUserLogin, visitUserProfilePage } from '../../utils/user'; - const user = new UserClass(); const domain = new Domain(); @@ -711,3 +716,105 @@ test.describe('Domains Rbac', () => { await afterAction(); }); }); + +test.describe('Data Consumer Domain Ownership', () => { + test.slow(true); + + const classification = new ClassificationClass({ + provider: 'system', + mutuallyExclusive: true, + }); + const tag = new TagClass({ + classification: classification.data.name, + }); + const glossary = new Glossary(); + const glossaryTerm = new GlossaryTerm(glossary); + + let testResources: { + dataConsumerUser: UserClass; + domainForTest: Domain; + dataProductForTest: DataProduct; + cleanup: (apiContext1: APIRequestContext) => Promise; + }; + + test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await classification.create(apiContext); + await tag.create(apiContext); + await glossary.create(apiContext); + await glossaryTerm.create(apiContext); + + testResources = await setupDomainOwnershipTest(apiContext); + + await afterAction(); + }); + + test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await tag.delete(apiContext); + await glossary.delete(apiContext); + await glossaryTerm.delete(apiContext); + await classification.delete(apiContext); + await testResources.cleanup(apiContext); + + await afterAction(); + }); + + test('Data consumer can manage domain as owner', async ({ browser }) => { + const { page: dataConsumerPage, afterAction: consumerAfterAction } = + await performUserLogin(browser, testResources.dataConsumerUser); + + await test.step( + 'Check domain management permissions for data consumer owner', + async () => { + await sidebarClick(dataConsumerPage, SidebarItem.DOMAIN); + await selectDomain(dataConsumerPage, testResources.domainForTest.data); + + await dataConsumerPage.getByTestId('domain-details-add-button').click(); + + // check Data Products menu item is visible + await expect( + dataConsumerPage.getByRole('menuitem', { + name: 'Data Products', + exact: true, + }) + ).toBeVisible(); + + await clickOutside(dataConsumerPage); + + await selectDataProductFromTab( + dataConsumerPage, + testResources.dataProductForTest.data + ); + + // Verify the user can edit owner, tags, glossary and domain experts + await expect(dataConsumerPage.getByTestId('edit-owner')).toBeVisible(); + await expect( + dataConsumerPage.getByTestId('tags-container').getByTestId('add-tag') + ).toBeVisible(); + + await expect( + dataConsumerPage + .getByTestId('glossary-container') + .getByTestId('add-tag') + ).toBeVisible(); + + await expect( + dataConsumerPage.getByTestId('domain-expert-name').getByTestId('Add') + ).toBeVisible(); + + await expect( + dataConsumerPage.getByTestId('manage-button') + ).toBeVisible(); + + await addTagsAndGlossaryToDomain(dataConsumerPage, { + tagFqn: tag.responseData.fullyQualifiedName, + glossaryTermFqn: glossaryTerm.responseData.fullyQualifiedName, + isDomain: false, + }); + } + ); + + await consumerAfterAction(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts index d6d3ef5431a..3d81e654343 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts @@ -10,9 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test, { expect, Page } from '@playwright/test'; +import test, { APIRequestContext, expect, Page } from '@playwright/test'; import { get, isEmpty, isUndefined } from 'lodash'; import { SidebarItem } from '../constant/sidebar'; +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 { SubDomain } from '../support/domain/SubDomain'; @@ -21,6 +23,8 @@ import { EntityTypeEndpoint } from '../support/entity/Entity.interface'; import { EntityClass } from '../support/entity/EntityClass'; import { TableClass } from '../support/entity/TableClass'; import { TopicClass } from '../support/entity/TopicClass'; +import { TeamClass } from '../support/team/TeamClass'; +import { UserClass } from '../support/user/UserClass'; import { closeFirstPopupAlert, descriptionBox, @@ -29,6 +33,7 @@ import { NAME_MAX_LENGTH_VALIDATION_ERROR, NAME_VALIDATION_ERROR, redirectToHomePage, + uuid, } from './common'; import { addOwner } from './entity'; import { sidebarClick } from './sidebar'; @@ -144,7 +149,10 @@ export const selectDataProductFromTab = async ( const dpRes = page.waitForResponse( '/api/v1/search/query?*&from=0&size=50&index=data_product_search_index' ); - await page.getByText('Data Products').click(); + await page + .locator('.domain-details-page-tabs') + .getByText('Data Products') + .click(); await dpRes; @@ -601,3 +609,107 @@ export const addTagsAndGlossaryToDomain = async ( // Add glossary term await addTagOrTerm('glossary', glossaryTermFqn); }; + +/** + * Verifies if the active domain is set to All Domains (DEFAULT_DOMAIN_VALUE) + */ +export const verifyActiveDomainIsDefault = async (page: Page) => { + await expect(page.getByTestId('domain-dropdown')).toContainText( + 'All Domains' + ); +}; + +/** + * Sets up a complete environment for domain ownership testing + * Creates user, policy, role, domain, data product and assigns ownership + * Returns all created objects and a cleanup function + */ +export const setupDomainOwnershipTest = async (apiContext: any) => { + // Create all necessary resources + const dataConsumerUser = new UserClass(); + const id = uuid(); + const domainForTest = new Domain({ + name: `PW_Domain_Owner_Rule_Testing-${id}`, + displayName: `PW_Domain_Owner_Rule_Testing-${id}`, + description: 'playwright domain description', + domainType: 'Aggregate', + fullyQualifiedName: `PW_Domain_Owner_Rule_Testing-${id}`, + }); + const dataProductForTest = new DataProduct( + domainForTest, + `PW_DataProduct_Owner_Rule-${id}` + ); + + await dataConsumerUser.create(apiContext); + await domainForTest.create(apiContext); + + // Setup permissions + const dataConsumerPolicy = new PolicyClass(); + const dataConsumerRole = new RolesClass(); + + // Create domain access policy + const domainRule = [ + { + name: 'DomainRule', + description: '', + resources: ['dataProduct', 'domain'], + operations: ['All'], + effect: 'allow', + condition: 'isOwner()', + }, + ]; + + await dataConsumerPolicy.create(apiContext, domainRule); + await dataConsumerRole.create(apiContext, [ + dataConsumerPolicy.responseData.name, + ]); + + await dataProductForTest.create(apiContext); + + // Create team for the user + const dataConsumerTeam = new TeamClass({ + name: `PW_data_consumer_team-${id}`, + displayName: `PW Data Consumer Team ${id}`, + description: 'playwright data consumer team description', + teamType: 'Group', + users: [dataConsumerUser.responseData.id ?? ''], + defaultRoles: [dataConsumerRole.responseData.id ?? ''], + }); + + await dataConsumerTeam.create(apiContext); + + // Set domain ownership + await domainForTest.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/owners/0', + value: { + id: dataConsumerUser.responseData.id, + type: 'user', + }, + }, + ], + }); + + // Return cleanup function and all created resources + const cleanup = async (apiContext1: APIRequestContext) => { + await dataProductForTest.delete(apiContext1); + await domainForTest.delete(apiContext1); + await dataConsumerUser.delete(apiContext1); + await dataConsumerTeam.delete(apiContext1); + await dataConsumerPolicy.delete(apiContext1); + await dataConsumerRole.delete(apiContext1); + }; + + return { + dataConsumerUser, + domainForTest, + dataProductForTest, + dataConsumerTeam, + dataConsumerPolicy, + dataConsumerRole, + cleanup, + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 6647b671182..0fedec00694 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -50,7 +50,6 @@ import { DataProduct, } from '../../../generated/entity/domains/dataProduct'; import { Domain } from '../../../generated/entity/domains/domain'; -import { Operation } from '../../../generated/entity/policies/policy'; import { Style } from '../../../generated/type/tagLabel'; import { useFqn } from '../../../hooks/useFqn'; import { QueryFilterInterface } from '../../../pages/ExplorePage/ExplorePage.interface'; @@ -59,10 +58,7 @@ import { getEntityDeleteMessage } from '../../../utils/CommonUtils'; import { getQueryFilterToIncludeDomain } from '../../../utils/DomainUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { getEntityVersionByField } from '../../../utils/EntityVersionUtils'; -import { - checkPermission, - DEFAULT_ENTITY_PERMISSION, -} from '../../../utils/PermissionsUtils'; +import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; import { getDomainPath } from '../../../utils/RouterUtils'; import { escapeESReservedCharacters, @@ -102,7 +98,7 @@ const DataProductsDetailsPage = ({ }: DataProductsDetailsPageProps) => { const { t } = useTranslation(); const history = useHistory(); - const { getEntityPermission, permissions } = usePermissionProvider(); + const { getEntityPermission } = usePermissionProvider(); const { tab: activeTab, version } = useParams<{ tab: string; version: string }>(); const { fqn: dataProductFqn } = useFqn(); @@ -158,7 +154,7 @@ const DataProductsDetailsPage = ({ const { editDisplayNamePermission, editAllPermission, - deleteDataProductPermision, + deleteDataProductPermission, } = useMemo(() => { if (isVersionsView) { return { @@ -168,44 +164,17 @@ const DataProductsDetailsPage = ({ }; } - const editDescription = checkPermission( - Operation.EditDescription, - ResourceEntity.DATA_PRODUCT, - permissions - ); - - const editOwner = checkPermission( - Operation.EditOwners, - ResourceEntity.DATA_PRODUCT, - permissions - ); - - const editAll = checkPermission( - Operation.EditAll, - ResourceEntity.DATA_PRODUCT, - permissions - ); - - const editDisplayName = checkPermission( - Operation.EditDisplayName, - ResourceEntity.DATA_PRODUCT, - permissions - ); - - const deleteDataProduct = checkPermission( - Operation.Delete, - ResourceEntity.DATA_PRODUCT, - permissions - ); - return { - editDescriptionPermission: editDescription || editAll, - editOwnerPermission: editOwner || editAll, - editAllPermission: editAll, - editDisplayNamePermission: editDisplayName || editAll, - deleteDataProductPermision: deleteDataProduct, + editDescriptionPermission: + dataProductPermission.EditDescription || dataProductPermission.EditAll, + editOwnerPermission: + dataProductPermission.EditOwners || dataProductPermission.EditAll, + editAllPermission: dataProductPermission.EditAll, + editDisplayNamePermission: + dataProductPermission.EditDisplayName || dataProductPermission.EditAll, + deleteDataProductPermission: dataProductPermission.Delete, }; - }, [permissions, isVersionsView]); + }, [dataProductPermission, isVersionsView]); const fetchDataProductAssets = async () => { if (dataProduct) { @@ -287,7 +256,7 @@ const DataProductsDetailsPage = ({ }, ] as ItemType[]) : []), - ...(deleteDataProductPermision + ...(deleteDataProductPermission ? ([ { label: ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx index b46180b573c..35f43e79070 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx @@ -27,7 +27,7 @@ import { useForm } from 'antd/lib/form/Form'; import { ItemType } from 'antd/lib/menu/hooks/useItems'; import { AxiosError } from 'axios'; import classNames from 'classnames'; -import { cloneDeep, isEmpty, toString } from 'lodash'; +import { cloneDeep, isEmpty, isEqual, toString } from 'lodash'; import React, { useCallback, useEffect, @@ -73,6 +73,7 @@ import { DataProduct } from '../../../generated/entity/domains/dataProduct'; import { Domain } from '../../../generated/entity/domains/domain'; import { ChangeDescription } from '../../../generated/entity/type'; import { Style } from '../../../generated/type/tagLabel'; +import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useFqn } from '../../../hooks/useFqn'; import { addDataProducts } from '../../../rest/dataProductAPI'; import { addDomains } from '../../../rest/domainAPI'; @@ -128,6 +129,8 @@ const DomainDetailsPage = ({ const { tab: activeTab, version } = useParams<{ tab: string; version: string }>(); const { fqn: domainFqn } = useFqn(); + const { currentUser } = useApplicationStore(); + const assetTabRef = useRef(null); const dataProductsTabRef = useRef(null); const [domainPermission, setDomainPermission] = useState( @@ -153,6 +156,11 @@ const DomainDetailsPage = ({ const isSubDomain = useMemo(() => !isEmpty(domain.parent), [domain]); + const isOwner = useMemo( + () => domain.owners?.some((owner) => isEqual(owner.id, currentUser?.id)), + [domain, currentUser] + ); + const breadcrumbs = useMemo(() => { if (!domainFqn) { return []; @@ -217,7 +225,7 @@ const DomainDetailsPage = ({ }, ] : []), - ...(permissions.dataProduct.Create + ...(isOwner || permissions.dataProduct.Create ? [ { label: t('label.data-product-plural'), @@ -283,7 +291,7 @@ const DomainDetailsPage = ({ ); const addDataProduct = useCallback( - async (formData: CreateDataProduct) => { + async (formData: CreateDomain | CreateDataProduct) => { const data = { ...formData, domain: domain.fullyQualifiedName,