mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-06-27 04:22:05 +00:00
Allow for domain-level permissions while creating/updating dataProduct
This commit is contained in:
parent
3bec47ef8a
commit
bfdf562642
@ -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.
|
||||
*
|
||||
* <p>As multiple threads don't access this, the class is not thread-safe by design.
|
||||
*/
|
||||
@Slf4j
|
||||
public class CreateResourceContext<T extends EntityInterface> implements ResourceContextInterface {
|
||||
@NonNull @Getter private final String resource;
|
||||
private final EntityRepository<T> entityRepository;
|
||||
@ -54,26 +59,47 @@ public class CreateResourceContext<T extends EntityInterface> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
*
|
||||
* <p>As multiple threads don't access this, the class is not thread-safe by design.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ResourceContext<T extends EntityInterface> implements ResourceContextInterface {
|
||||
@NonNull @Getter private final String resource;
|
||||
private final EntityRepository<T> entityRepository;
|
||||
@ -73,7 +81,36 @@ public class ResourceContext<T extends EntityInterface> 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<EntityReference> 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
|
||||
|
@ -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<DataProduct> 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<List<TagLabel>>) 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<CompiledRule> 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.");
|
||||
}
|
||||
}
|
||||
|
@ -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<void>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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: (
|
||||
|
@ -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<AssetsTabRef>(null);
|
||||
const dataProductsTabRef = useRef<DataProductsTabRef>(null);
|
||||
const [domainPermission, setDomainPermission] = useState<OperationPermission>(
|
||||
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user