From 393e1d2d8322acefa011d988dbed4c9ab2cfa8db Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Wed, 20 Mar 2024 15:25:36 -0400 Subject: [PATCH] feat(properties) Add upsertStructuredProperties graphql endpoint for assets (#9906) --- .../datahub/graphql/GmsGraphQLEngine.java | 15 ++ .../authorization/AuthorizationUtils.java | 18 ++ .../entity/EntityPrivilegesResolver.java | 23 +- .../resolvers/mutate/util/FormUtils.java | 10 +- .../mutate/util/StructuredPropertyUtils.java | 22 ++ .../UpsertStructuredPropertiesResolver.java | 172 +++++++++++++ .../src/main/resources/auth.graphql | 5 + .../src/main/resources/entity.graphql | 55 ++++ .../src/main/resources/properties.graphql | 22 ++ ...psertStructuredPropertiesResolverTest.java | 242 ++++++++++++++++++ datahub-web-react/src/Mocks.tsx | 33 +-- .../styled/StructuredProperty}/DateInput.tsx | 0 .../StructuredProperty}/DropdownLabel.tsx | 0 .../StructuredProperty}/MultiSelectInput.tsx | 2 +- .../MultipleStringInput.tsx | 0 .../StructuredProperty}/NumberInput.tsx | 0 .../StructuredProperty}/RichTextInput.tsx | 0 .../StructuredProperty}/SingleSelectInput.tsx | 2 +- .../StructuredProperty}/StringInput.tsx | 0 .../StructuredPropertyInput.tsx | 74 ++++++ .../useEditStructuredProperty.ts | 35 +++ .../StructuredPropertyPrompt.tsx | 78 +----- .../useStructuredPropertyPrompt.ts | 44 ++-- .../tabs/Properties/Edit/EditColumn.tsx | 30 +++ .../Edit/EditStructuredPropertyModal.tsx | 98 +++++++ .../shared/tabs/Properties/PropertiesTab.tsx | 9 + .../lineage/__tests__/constructTree.test.ts | 6 +- datahub-web-react/src/graphql/chart.graphql | 3 +- .../src/graphql/container.graphql | 3 + .../src/graphql/dashboard.graphql | 3 + .../src/graphql/dataFlow.graphql | 3 + datahub-web-react/src/graphql/dataJob.graphql | 3 + .../src/graphql/dataProduct.graphql | 3 + datahub-web-react/src/graphql/dataset.graphql | 4 +- datahub-web-react/src/graphql/domain.graphql | 3 + .../src/graphql/fragments.graphql | 9 + .../src/graphql/glossaryNode.graphql | 3 +- .../src/graphql/glossaryTerm.graphql | 2 +- datahub-web-react/src/graphql/group.graphql | 3 + .../src/graphql/mlFeature.graphql | 3 + .../src/graphql/mlFeatureTable.graphql | 3 + datahub-web-react/src/graphql/mlModel.graphql | 3 + .../src/graphql/mlModelGroup.graphql | 3 + .../src/graphql/mlPrimaryKey.graphql | 3 + .../src/graphql/structuredProperties.graphql | 7 + datahub-web-react/src/graphql/user.graphql | 3 + .../war/src/main/resources/boot/policies.json | 9 +- .../authorization/PoliciesConfig.java | 25 +- 48 files changed, 951 insertions(+), 145 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/StructuredPropertyUtils.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolverTest.java rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/DateInput.tsx (100%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/DropdownLabel.tsx (100%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/MultiSelectInput.tsx (96%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/MultipleStringInput.tsx (100%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/NumberInput.tsx (100%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/RichTextInput.tsx (100%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/SingleSelectInput.tsx (95%) rename datahub-web-react/src/app/entity/shared/{entityForm/prompts/StructuredPropertyPrompt => components/styled/StructuredProperty}/StringInput.tsx (100%) create mode 100644 datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/useEditStructuredProperty.ts create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx create mode 100644 datahub-web-react/src/graphql/structuredProperties.graphql diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 7bf892ff2b5d5c..481aed26c9f25c 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -257,6 +257,7 @@ import com.linkedin.datahub.graphql.resolvers.settings.view.UpdateGlobalViewsSettingsResolver; import com.linkedin.datahub.graphql.resolvers.step.BatchGetStepStatesResolver; import com.linkedin.datahub.graphql.resolvers.step.BatchUpdateStepStatesResolver; +import com.linkedin.datahub.graphql.resolvers.structuredproperties.UpsertStructuredPropertiesResolver; import com.linkedin.datahub.graphql.resolvers.tag.CreateTagResolver; import com.linkedin.datahub.graphql.resolvers.tag.DeleteTagResolver; import com.linkedin.datahub.graphql.resolvers.tag.SetTagColorResolver; @@ -844,6 +845,7 @@ private void configureContainerResolvers(final RuntimeWiring.Builder builder) { typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("entities", new ContainerEntitiesResolver(entityClient)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService)) @@ -1243,6 +1245,9 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher( "verifyForm", new VerifyFormResolver(this.formService, this.groupService)) .dataFetcher("batchRemoveForm", new BatchRemoveFormResolver(this.formService)) + .dataFetcher( + "upsertStructuredProperties", + new UpsertStructuredPropertiesResolver(this.entityClient)) .dataFetcher("raiseIncident", new RaiseIncidentResolver(this.entityClient)) .dataFetcher( "updateIncidentStatus", @@ -1719,6 +1724,7 @@ private void configureCorpUserResolvers(final RuntimeWiring.Builder builder) { typeWiring -> typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry))); builder.type( @@ -1741,6 +1747,7 @@ private void configureCorpGroupResolvers(final RuntimeWiring.Builder builder) { typeWiring -> typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService))); @@ -2253,6 +2260,7 @@ private void configureDataFlowResolvers(final RuntimeWiring.Builder builder) { dataPlatformType, (env) -> ((DataFlow) env.getSource()).getPlatform().getUrn())) .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "dataPlatformInstance", new LoadableTypeResolver<>( @@ -2300,6 +2308,7 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde new LoadableTypeResolver<>( dataPlatformType, (env) -> ((MLFeatureTable) env.getSource()).getPlatform().getUrn())) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "dataPlatformInstance", new LoadableTypeResolver<>( @@ -2390,6 +2399,7 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde new LoadableTypeResolver<>( dataPlatformType, (env) -> ((MLModel) env.getSource()).getPlatform().getUrn())) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "dataPlatformInstance", new LoadableTypeResolver<>( @@ -2438,6 +2448,7 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde dataPlatformType, (env) -> ((MLModelGroup) env.getSource()).getPlatform().getUrn())) .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "dataPlatformInstance", new LoadableTypeResolver<>( @@ -2463,6 +2474,7 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde .dataFetcher( "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "dataPlatformInstance", new LoadableTypeResolver<>( @@ -2479,6 +2491,7 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde typeWiring .dataFetcher( "relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "lineage", new EntityLineageResultResolver( @@ -2521,6 +2534,7 @@ private void configureDomainResolvers(final RuntimeWiring.Builder builder) { typeWiring .dataFetcher("entities", new DomainEntitiesResolver(this.entityClient)) .dataFetcher("parentDomains", new ParentDomainsResolver(this.entityClient)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient))); @@ -2593,6 +2607,7 @@ private void configureDataProductResolvers(final RuntimeWiring.Builder builder) typeWiring -> typeWiring .dataFetcher("entities", new ListDataProductAssetsResolver(this.entityClient)) + .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) .dataFetcher( "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient))); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java index 1a935d530505be..f2f06f9c2c47ff 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java @@ -154,6 +154,24 @@ public static boolean canManageOwnershipTypes(@Nonnull QueryContext context) { return isAuthorized(context, Optional.empty(), PoliciesConfig.MANAGE_GLOBAL_OWNERSHIP_TYPES); } + public static boolean canEditProperties(@Nonnull Urn targetUrn, @Nonnull QueryContext context) { + // If you either have all entity privileges, or have the specific privileges required, you are + // authorized. + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + ALL_PRIVILEGES_GROUP, + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_PROPERTIES_PRIVILEGE.getType())))); + + return AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getActorUrn(), + targetUrn.getEntityType(), + targetUrn.toString(), + orPrivilegeGroups); + } + public static boolean canEditEntityQueries( @Nonnull List entityUrns, @Nonnull QueryContext context) { final DisjunctivePrivilegeGroup orPrivilegeGroups = diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityPrivilegesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityPrivilegesResolver.java index 751c6096de1a2d..92f090946db93c 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityPrivilegesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityPrivilegesResolver.java @@ -18,6 +18,7 @@ import graphql.schema.DataFetchingEnvironment; import java.util.Collections; import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -52,15 +53,18 @@ public CompletableFuture get(DataFetchingEnvironment environme return getDataJobPrivileges(urn, context); default: log.warn( - "Tried to get entity privileges for entity type {} but nothing is implemented for it yet", + "Tried to get entity privileges for entity type {}. Adding common privileges only.", urn.getEntityType()); - return new EntityPrivileges(); + EntityPrivileges commonPrivileges = new EntityPrivileges(); + addCommonPrivileges(commonPrivileges, urn, context); + return commonPrivileges; } }); } private EntityPrivileges getGlossaryTermPrivileges(Urn termUrn, QueryContext context) { final EntityPrivileges result = new EntityPrivileges(); + addCommonPrivileges(result, termUrn, context); result.setCanManageEntity(false); if (GlossaryUtils.canManageGlossaries(context)) { result.setCanManageEntity(true); @@ -77,6 +81,7 @@ private EntityPrivileges getGlossaryTermPrivileges(Urn termUrn, QueryContext con private EntityPrivileges getGlossaryNodePrivileges(Urn nodeUrn, QueryContext context) { final EntityPrivileges result = new EntityPrivileges(); + addCommonPrivileges(result, nodeUrn, context); result.setCanManageEntity(false); if (GlossaryUtils.canManageGlossaries(context)) { result.setCanManageEntity(true); @@ -117,29 +122,35 @@ private boolean canEditEntityLineage(Urn urn, QueryContext context) { private EntityPrivileges getDatasetPrivileges(Urn urn, QueryContext context) { final EntityPrivileges result = new EntityPrivileges(); - result.setCanEditLineage(canEditEntityLineage(urn, context)); result.setCanEditEmbed(EmbedUtils.isAuthorizedToUpdateEmbedForEntity(urn, context)); result.setCanEditQueries(AuthorizationUtils.canCreateQuery(ImmutableList.of(urn), context)); + addCommonPrivileges(result, urn, context); return result; } private EntityPrivileges getChartPrivileges(Urn urn, QueryContext context) { final EntityPrivileges result = new EntityPrivileges(); - result.setCanEditLineage(canEditEntityLineage(urn, context)); result.setCanEditEmbed(EmbedUtils.isAuthorizedToUpdateEmbedForEntity(urn, context)); + addCommonPrivileges(result, urn, context); return result; } private EntityPrivileges getDashboardPrivileges(Urn urn, QueryContext context) { final EntityPrivileges result = new EntityPrivileges(); - result.setCanEditLineage(canEditEntityLineage(urn, context)); result.setCanEditEmbed(EmbedUtils.isAuthorizedToUpdateEmbedForEntity(urn, context)); + addCommonPrivileges(result, urn, context); return result; } private EntityPrivileges getDataJobPrivileges(Urn urn, QueryContext context) { final EntityPrivileges result = new EntityPrivileges(); - result.setCanEditLineage(canEditEntityLineage(urn, context)); + addCommonPrivileges(result, urn, context); return result; } + + private void addCommonPrivileges( + @Nonnull EntityPrivileges result, @Nonnull Urn urn, @Nonnull QueryContext context) { + result.setCanEditLineage(canEditEntityLineage(urn, context)); + result.setCanEditProperties(AuthorizationUtils.canEditProperties(urn, context)); + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java index 25768da8195557..9a06682c87f78f 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java @@ -12,7 +12,6 @@ import com.linkedin.metadata.query.filter.Criterion; import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.query.filter.Filter; -import com.linkedin.structured.PrimitivePropertyValue; import com.linkedin.structured.PrimitivePropertyValueArray; import java.util.Objects; import javax.annotation.Nonnull; @@ -37,14 +36,7 @@ public static PrimitivePropertyValueArray getStructuredPropertyValuesFromInput( input .getStructuredPropertyParams() .getValues() - .forEach( - value -> { - if (value.getStringValue() != null) { - values.add(PrimitivePropertyValue.create(value.getStringValue())); - } else if (value.getNumberValue() != null) { - values.add(PrimitivePropertyValue.create(value.getNumberValue().doubleValue())); - } - }); + .forEach(value -> values.add(StructuredPropertyUtils.mapPropertyValueInput(value))); return values; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/StructuredPropertyUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/StructuredPropertyUtils.java new file mode 100644 index 00000000000000..8c4e70fdac6055 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/StructuredPropertyUtils.java @@ -0,0 +1,22 @@ +package com.linkedin.datahub.graphql.resolvers.mutate.util; + +import com.linkedin.datahub.graphql.generated.PropertyValueInput; +import com.linkedin.structured.PrimitivePropertyValue; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class StructuredPropertyUtils { + + private StructuredPropertyUtils() {} + + @Nullable + public static PrimitivePropertyValue mapPropertyValueInput( + @Nonnull final PropertyValueInput valueInput) { + if (valueInput.getStringValue() != null) { + return PrimitivePropertyValue.create(valueInput.getStringValue()); + } else if (valueInput.getNumberValue() != null) { + return PrimitivePropertyValue.create(valueInput.getNumberValue().doubleValue()); + } + return null; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java new file mode 100644 index 00000000000000..d440c5cf05d8f9 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java @@ -0,0 +1,172 @@ +package com.linkedin.datahub.graphql.resolvers.structuredproperties; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.PropertyValueInput; +import com.linkedin.datahub.graphql.generated.UpsertStructuredPropertiesInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.StructuredPropertyUtils; +import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertiesMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.AspectUtils; +import com.linkedin.metadata.utils.AuditStampUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.structured.PrimitivePropertyValueArray; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import com.linkedin.structured.StructuredPropertyValueAssignmentArray; +import graphql.com.google.common.collect.ImmutableSet; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class UpsertStructuredPropertiesResolver + implements DataFetcher< + CompletableFuture> { + + private final EntityClient _entityClient; + + public UpsertStructuredPropertiesResolver(@Nonnull final EntityClient entityClient) { + _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null"); + } + + @Override + public CompletableFuture get( + final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final Authentication authentication = context.getAuthentication(); + + final UpsertStructuredPropertiesInput input = + bindArgument(environment.getArgument("input"), UpsertStructuredPropertiesInput.class); + final Urn assetUrn = UrnUtils.getUrn(input.getAssetUrn()); + Map> updateMap = new HashMap<>(); + // create a map of updates from our input + input + .getStructuredPropertyInputParams() + .forEach(param -> updateMap.put(param.getStructuredPropertyUrn(), param.getValues())); + + return CompletableFuture.supplyAsync( + () -> { + try { + // check authorization first + if (!AuthorizationUtils.canEditProperties(assetUrn, context)) { + throw new AuthorizationException( + String.format( + "Not authorized to update properties on the gives urn %s", assetUrn)); + } + + final AuditStamp auditStamp = + AuditStampUtils.createAuditStamp(authentication.getActor().toUrnStr()); + + if (!_entityClient.exists(assetUrn, authentication)) { + throw new RuntimeException( + String.format("Asset with provided urn %s does not exist", assetUrn)); + } + + // get or default the structured properties aspect + StructuredProperties structuredProperties = + getStructuredProperties(assetUrn, authentication); + + // update the existing properties based on new value + StructuredPropertyValueAssignmentArray properties = + updateExistingProperties(structuredProperties, updateMap, auditStamp); + + // append any new properties from our input + addNewProperties(properties, updateMap, auditStamp); + + structuredProperties.setProperties(properties); + + // ingest change proposal + final MetadataChangeProposal structuredPropertiesProposal = + AspectUtils.buildMetadataChangeProposal( + assetUrn, STRUCTURED_PROPERTIES_ASPECT_NAME, structuredProperties); + + _entityClient.ingestProposal(structuredPropertiesProposal, authentication, false); + + return StructuredPropertiesMapper.map(structuredProperties); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } + + private StructuredProperties getStructuredProperties(Urn assetUrn, Authentication authentication) + throws Exception { + EntityResponse response = + _entityClient.getV2( + assetUrn.getEntityType(), + assetUrn, + ImmutableSet.of(STRUCTURED_PROPERTIES_ASPECT_NAME), + authentication); + StructuredProperties structuredProperties = new StructuredProperties(); + structuredProperties.setProperties(new StructuredPropertyValueAssignmentArray()); + if (response != null && response.getAspects().containsKey(STRUCTURED_PROPERTIES_ASPECT_NAME)) { + structuredProperties = + new StructuredProperties( + response.getAspects().get(STRUCTURED_PROPERTIES_ASPECT_NAME).getValue().data()); + } + return structuredProperties; + } + + private StructuredPropertyValueAssignmentArray updateExistingProperties( + StructuredProperties structuredProperties, + Map> updateMap, + AuditStamp auditStamp) { + return new StructuredPropertyValueAssignmentArray( + structuredProperties.getProperties().stream() + .map( + propAssignment -> { + String propUrnString = propAssignment.getPropertyUrn().toString(); + if (updateMap.containsKey(propUrnString)) { + List valueList = updateMap.get(propUrnString); + PrimitivePropertyValueArray values = + new PrimitivePropertyValueArray( + valueList.stream() + .map(StructuredPropertyUtils::mapPropertyValueInput) + .collect(Collectors.toList())); + propAssignment.setValues(values); + propAssignment.setLastModified(auditStamp); + } + return propAssignment; + }) + .collect(Collectors.toList())); + } + + private void addNewProperties( + StructuredPropertyValueAssignmentArray properties, + Map> updateMap, + AuditStamp auditStamp) { + // first remove existing properties from updateMap so that we append only new properties + properties.forEach(prop -> updateMap.remove(prop.getPropertyUrn().toString())); + + updateMap.forEach( + (structuredPropUrn, values) -> { + StructuredPropertyValueAssignment valueAssignment = + new StructuredPropertyValueAssignment(); + valueAssignment.setPropertyUrn(UrnUtils.getUrn(structuredPropUrn)); + valueAssignment.setValues( + new PrimitivePropertyValueArray( + values.stream() + .map(StructuredPropertyUtils::mapPropertyValueInput) + .collect(Collectors.toList()))); + valueAssignment.setCreated(auditStamp); + valueAssignment.setLastModified(auditStamp); + properties.add(valueAssignment); + }); + } +} diff --git a/datahub-graphql-core/src/main/resources/auth.graphql b/datahub-graphql-core/src/main/resources/auth.graphql index c7dc6be137beac..83c63b6be18b87 100644 --- a/datahub-graphql-core/src/main/resources/auth.graphql +++ b/datahub-graphql-core/src/main/resources/auth.graphql @@ -274,4 +274,9 @@ type EntityPrivileges { Whether or not a user can update the Queries for the entity (e.g. dataset) """ canEditQueries: Boolean + + """ + Whether or not a user can update the properties for the entity (e.g. dataset) + """ + canEditProperties: Boolean } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index c217620dbf6cd8..b939b86813e73b 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -2748,6 +2748,11 @@ type Container implements Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ @@ -3792,6 +3797,11 @@ type CorpUser implements Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ @@ -4168,6 +4178,11 @@ type CorpGroup implements Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ @@ -6153,6 +6168,11 @@ type DataFlow implements EntityWithRelationships & Entity & BrowsableEntity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ @@ -9340,6 +9360,11 @@ type MLModel implements EntityWithRelationships & Entity & BrowsableEntity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ @@ -9467,6 +9492,11 @@ type MLModelGroup implements EntityWithRelationships & Entity & BrowsableEntity The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } type MLModelGroupProperties { @@ -9607,6 +9637,11 @@ type MLFeature implements EntityWithRelationships & Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } type MLHyperParam { @@ -9792,6 +9827,11 @@ type MLPrimaryKey implements EntityWithRelationships & Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } type MLPrimaryKeyProperties { @@ -9935,6 +9975,11 @@ type MLFeatureTable implements EntityWithRelationships & Entity & BrowsableEntit The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } type MLFeatureTableEditableProperties { @@ -10342,6 +10387,11 @@ type Domain implements Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ @@ -11755,6 +11805,11 @@ type DataProduct implements Entity { The forms associated with the Dataset """ forms: Forms + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges } """ diff --git a/datahub-graphql-core/src/main/resources/properties.graphql b/datahub-graphql-core/src/main/resources/properties.graphql index 2bed0f1155ff1c..3bf0bbefc406d7 100644 --- a/datahub-graphql-core/src/main/resources/properties.graphql +++ b/datahub-graphql-core/src/main/resources/properties.graphql @@ -1,3 +1,10 @@ +extend type Mutation { + """ + Upsert structured properties onto a given asset + """ + upsertStructuredProperties(input: UpsertStructuredPropertiesInput!): StructuredProperties! +} + """ A structured property that can be shared between different entities """ @@ -157,6 +164,21 @@ type StructuredPropertiesEntry { valueEntities: [Entity] } +""" +Input for upserting structured properties on a given asset +""" +input UpsertStructuredPropertiesInput { + """ + The urn of the asset that we are updating + """ + assetUrn: String! + + """ + The list of structured properties you want to upsert on this asset + """ + structuredPropertyInputParams: [StructuredPropertyInputParams!]! +} + """ A data type registered in DataHub """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolverTest.java new file mode 100644 index 00000000000000..3c97d64a745bc8 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolverTest.java @@ -0,0 +1,242 @@ +package com.linkedin.datahub.graphql.resolvers.structuredproperties; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertThrows; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.PropertyValueInput; +import com.linkedin.datahub.graphql.generated.StringValue; +import com.linkedin.datahub.graphql.generated.StructuredPropertyInputParams; +import com.linkedin.datahub.graphql.generated.UpsertStructuredPropertiesInput; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PrimitivePropertyValueArray; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import com.linkedin.structured.StructuredPropertyValueAssignmentArray; +import graphql.com.google.common.collect.ImmutableList; +import graphql.com.google.common.collect.ImmutableSet; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import javax.annotation.Nullable; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class UpsertStructuredPropertiesResolverTest { + private static final String TEST_DATASET_URN = + "urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD)"; + private static final String PROPERTY_URN_1 = "urn:li:structuredProperty:test1"; + private static final String PROPERTY_URN_2 = "urn:li:structuredProperty:test2"; + + private static final StructuredPropertyInputParams PROP_INPUT_1 = + new StructuredPropertyInputParams( + PROPERTY_URN_1, ImmutableList.of(new PropertyValueInput("test1", null))); + private static final StructuredPropertyInputParams PROP_INPUT_2 = + new StructuredPropertyInputParams( + PROPERTY_URN_2, ImmutableList.of(new PropertyValueInput("test2", null))); + private static final UpsertStructuredPropertiesInput TEST_INPUT = + new UpsertStructuredPropertiesInput( + TEST_DATASET_URN, ImmutableList.of(PROP_INPUT_1, PROP_INPUT_2)); + + @Test + public void testGetSuccessUpdateExisting() throws Exception { + // mock it so that this entity already has values for the given two properties + StructuredPropertyValueAssignmentArray initialProperties = + new StructuredPropertyValueAssignmentArray(); + PrimitivePropertyValueArray propertyValues = new PrimitivePropertyValueArray(); + propertyValues.add(PrimitivePropertyValue.create("hello")); + initialProperties.add( + new StructuredPropertyValueAssignment() + .setPropertyUrn(UrnUtils.getUrn(PROPERTY_URN_1)) + .setValues(propertyValues)); + initialProperties.add( + new StructuredPropertyValueAssignment() + .setPropertyUrn(UrnUtils.getUrn(PROPERTY_URN_2)) + .setValues(propertyValues)); + EntityClient mockEntityClient = initMockEntityClient(true, initialProperties); + UpsertStructuredPropertiesResolver resolver = + new UpsertStructuredPropertiesResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + com.linkedin.datahub.graphql.generated.StructuredProperties result = + resolver.get(mockEnv).get(); + + assertEquals(result.getProperties().size(), 2); + assertEquals(result.getProperties().get(0).getStructuredProperty().getUrn(), PROPERTY_URN_1); + assertEquals(result.getProperties().get(0).getValues().size(), 1); + assertEquals( + result.getProperties().get(0).getValues().get(0).toString(), + new StringValue("test1").toString()); + assertEquals(result.getProperties().get(1).getStructuredProperty().getUrn(), PROPERTY_URN_2); + assertEquals(result.getProperties().get(1).getValues().size(), 1); + assertEquals( + result.getProperties().get(1).getValues().get(0).toString(), + new StringValue("test2").toString()); + + // Validate that we called ingestProposal the correct number of times + Mockito.verify(mockEntityClient, Mockito.times(1)) + .ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(Authentication.class), + Mockito.eq(false)); + } + + @Test + public void testGetSuccessNoExistingProps() throws Exception { + // mock so the original entity has no structured properties + EntityClient mockEntityClient = initMockEntityClient(true, null); + UpsertStructuredPropertiesResolver resolver = + new UpsertStructuredPropertiesResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + com.linkedin.datahub.graphql.generated.StructuredProperties result = + resolver.get(mockEnv).get(); + + assertEquals(result.getProperties().size(), 2); + assertEquals(result.getProperties().get(0).getStructuredProperty().getUrn(), PROPERTY_URN_2); + assertEquals(result.getProperties().get(0).getValues().size(), 1); + assertEquals( + result.getProperties().get(0).getValues().get(0).toString(), + new StringValue("test2").toString()); + assertEquals(result.getProperties().get(1).getStructuredProperty().getUrn(), PROPERTY_URN_1); + assertEquals(result.getProperties().get(1).getValues().size(), 1); + assertEquals( + result.getProperties().get(1).getValues().get(0).toString(), + new StringValue("test1").toString()); + + // Validate that we called ingestProposal the correct number of times + Mockito.verify(mockEntityClient, Mockito.times(1)) + .ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(Authentication.class), + Mockito.eq(false)); + } + + @Test + public void testGetSuccessOneExistingOneNew() throws Exception { + // mock so the original entity has one of the input props and one is new + StructuredPropertyValueAssignmentArray initialProperties = + new StructuredPropertyValueAssignmentArray(); + PrimitivePropertyValueArray propertyValues = new PrimitivePropertyValueArray(); + propertyValues.add(PrimitivePropertyValue.create("hello")); + initialProperties.add( + new StructuredPropertyValueAssignment() + .setPropertyUrn(UrnUtils.getUrn(PROPERTY_URN_1)) + .setValues(propertyValues)); + EntityClient mockEntityClient = initMockEntityClient(true, initialProperties); + UpsertStructuredPropertiesResolver resolver = + new UpsertStructuredPropertiesResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + com.linkedin.datahub.graphql.generated.StructuredProperties result = + resolver.get(mockEnv).get(); + + assertEquals(result.getProperties().size(), 2); + assertEquals(result.getProperties().get(0).getStructuredProperty().getUrn(), PROPERTY_URN_1); + assertEquals(result.getProperties().get(0).getValues().size(), 1); + assertEquals( + result.getProperties().get(0).getValues().get(0).toString(), + new StringValue("test1").toString()); + assertEquals(result.getProperties().get(1).getStructuredProperty().getUrn(), PROPERTY_URN_2); + assertEquals(result.getProperties().get(1).getValues().size(), 1); + assertEquals( + result.getProperties().get(1).getValues().get(0).toString(), + new StringValue("test2").toString()); + + // Validate that we called ingestProposal the correct number of times + Mockito.verify(mockEntityClient, Mockito.times(1)) + .ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(Authentication.class), + Mockito.eq(false)); + } + + @Test + public void testThrowsError() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(false, null); + UpsertStructuredPropertiesResolver resolver = + new UpsertStructuredPropertiesResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that we called ingestProposal the correct number of times + Mockito.verify(mockEntityClient, Mockito.times(0)) + .ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(Authentication.class), + Mockito.eq(false)); + } + + private EntityClient initMockEntityClient( + final boolean shouldSucceed, @Nullable StructuredPropertyValueAssignmentArray properties) + throws Exception { + Urn assetUrn = UrnUtils.getUrn(TEST_DATASET_URN); + EntityClient client = Mockito.mock(EntityClient.class); + + Mockito.when(client.exists(Mockito.eq(assetUrn), Mockito.any())).thenReturn(true); + + if (!shouldSucceed) { + Mockito.doThrow(new RuntimeException()) + .when(client) + .getV2(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(Authentication.class)); + } else { + if (properties == null) { + Mockito.when( + client.getV2( + Mockito.eq(assetUrn.getEntityType()), + Mockito.eq(assetUrn), + Mockito.eq(ImmutableSet.of(STRUCTURED_PROPERTIES_ASPECT_NAME)), + Mockito.any(Authentication.class))) + .thenReturn(null); + } else { + StructuredProperties structuredProps = new StructuredProperties(); + structuredProps.setProperties(properties); + EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + aspectMap.put( + STRUCTURED_PROPERTIES_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(structuredProps.data()))); + EntityResponse response = new EntityResponse(); + response.setAspects(aspectMap); + Mockito.when( + client.getV2( + Mockito.eq(assetUrn.getEntityType()), + Mockito.eq(assetUrn), + Mockito.eq(ImmutableSet.of(STRUCTURED_PROPERTIES_ASPECT_NAME)), + Mockito.any(Authentication.class))) + .thenReturn(response); + } + } + return client; + } +} diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index 2a878ed8208862..78b72c26f4d1a0 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -29,6 +29,7 @@ import { PlatformPrivileges, FilterOperator, AppConfig, + EntityPrivileges, } from './types.generated'; import { GetTagDocument } from './graphql/tag.generated'; import { GetMlModelDocument } from './graphql/mlModel.generated'; @@ -43,6 +44,16 @@ import { GetQuickFiltersDocument } from './graphql/quickFilters.generated'; import { GetGrantedPrivilegesDocument } from './graphql/policy.generated'; import { VIEW_ENTITY_PAGE } from './app/entity/shared/constants'; +export const entityPrivileges: EntityPrivileges = { + canEditLineage: true, + canManageEntity: true, + canManageChildren: true, + canEditEmbed: true, + canEditQueries: true, + canEditProperties: true, + __typename: 'EntityPrivileges', +}; + export const user1 = { __typename: 'CorpUser', username: 'sdas', @@ -211,9 +222,7 @@ export const dataset1 = { tags: ['Private', 'PII'], uri: 'www.google.com', privileges: { - canEditLineage: false, - canEditEmbed: false, - canEditQueries: false, + ...entityPrivileges, }, properties: { name: 'The Great Test Dataset', @@ -318,9 +327,7 @@ export const dataset2 = { type: EntityType.DataPlatform, }, privileges: { - canEditLineage: false, - canEditEmbed: false, - canEditQueries: false, + ...entityPrivileges, }, lastIngested: null, exists: true, @@ -422,10 +429,7 @@ export const dataset3 = { properties: null, }, privileges: { - __typename: 'EntityPrivileges', - canEditLineage: false, - canEditEmbed: false, - canEditQueries: false, + ...entityPrivileges, }, exists: true, lastIngested: null, @@ -1384,8 +1388,7 @@ export const dataJob1 = { }, }, privileges: { - canEditLineage: false, - canEditEmbed: false, + ...entityPrivileges, }, properties: { name: 'DataJobInfoName', @@ -1449,8 +1452,7 @@ export const dataJob2 = { dataFlow: dataFlow1, jobId: 'jobId2', privileges: { - canEditLineage: false, - canEditEmbed: false, + ...entityPrivileges, }, ownership: { __typename: 'Ownership', @@ -1523,8 +1525,7 @@ export const dataJob3 = { lastIngested: null, exists: true, privileges: { - canEditLineage: false, - canEditEmbed: false, + ...entityPrivileges, }, ownership: { __typename: 'Ownership', diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DateInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/DateInput.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DateInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/DateInput.tsx diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DropdownLabel.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/DropdownLabel.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DropdownLabel.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/DropdownLabel.tsx diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/MultiSelectInput.tsx similarity index 96% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/MultiSelectInput.tsx index f7d0ed2d211259..cc28effa87bec8 100644 --- a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/MultiSelectInput.tsx @@ -3,7 +3,7 @@ import React from 'react'; import styled from 'styled-components'; import { ANTD_GRAY_V2 } from '../../../constants'; import { getStructuredPropertyValue } from '../../../utils'; -import ValueDescription from './ValueDescription'; +import ValueDescription from '../../../entityForm/prompts/StructuredPropertyPrompt/ValueDescription'; import { AllowedValue } from '../../../../../../types.generated'; import DropdownLabel from './DropdownLabel'; diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultipleStringInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/MultipleStringInput.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultipleStringInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/MultipleStringInput.tsx diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/NumberInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/NumberInput.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/NumberInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/NumberInput.tsx diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/RichTextInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/RichTextInput.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/RichTextInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/RichTextInput.tsx diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/SingleSelectInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/SingleSelectInput.tsx similarity index 95% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/SingleSelectInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/SingleSelectInput.tsx index 8325c215bbdf8d..93f0cf59ad6e07 100644 --- a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/SingleSelectInput.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/SingleSelectInput.tsx @@ -3,7 +3,7 @@ import React from 'react'; import styled from 'styled-components'; import { ANTD_GRAY_V2 } from '../../../constants'; import { getStructuredPropertyValue } from '../../../utils'; -import ValueDescription from './ValueDescription'; +import ValueDescription from '../../../entityForm/prompts/StructuredPropertyPrompt/ValueDescription'; import { AllowedValue } from '../../../../../../types.generated'; import DropdownLabel from './DropdownLabel'; diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StringInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StringInput.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StringInput.tsx rename to datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StringInput.tsx diff --git a/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx new file mode 100644 index 00000000000000..894a304335b0f6 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { PropertyCardinality, StdDataType, StructuredPropertyEntity } from '../../../../../../types.generated'; +import SingleSelectInput from './SingleSelectInput'; +import MultiSelectInput from './MultiSelectInput'; +import StringInput from './StringInput'; +import RichTextInput from './RichTextInput'; +import DateInput from './DateInput'; +import NumberInput from './NumberInput'; +import UrnInput from '../../../entityForm/prompts/StructuredPropertyPrompt/UrnInput/UrnInput'; + +interface Props { + structuredProperty: StructuredPropertyEntity; + selectedValues: (string | number | null)[]; + selectSingleValue: (value: string | number) => void; + toggleSelectedValue: (value: string | number) => void; + updateSelectedValues: (value: (string | number | null)[]) => void; +} + +export default function StructuredPropertyInput({ + structuredProperty, + selectSingleValue, + selectedValues, + toggleSelectedValue, + updateSelectedValues, +}: Props) { + const { allowedValues, cardinality, valueType } = structuredProperty.definition; + + return ( + <> + {allowedValues && allowedValues.length > 0 && ( + <> + {cardinality === PropertyCardinality.Single && ( + + )} + {cardinality === PropertyCardinality.Multiple && ( + + )} + + )} + {!allowedValues && valueType.info.type === StdDataType.String && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.RichText && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.Date && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.Number && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.Urn && ( + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/useEditStructuredProperty.ts b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/useEditStructuredProperty.ts new file mode 100644 index 00000000000000..ae07ec4db5875b --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/useEditStructuredProperty.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; + +export function useEditStructuredProperty(initialValues?: (string | number | null)[]) { + const [hasEdited, setHasEdited] = useState(false); + const [selectedValues, setSelectedValues] = useState(initialValues || []); + + function selectSingleValue(value: string | number) { + setHasEdited(true); + setSelectedValues([value as string]); + } + + function toggleSelectedValue(value: string | number) { + setHasEdited(true); + if (selectedValues.includes(value)) { + setSelectedValues((prev) => prev.filter((v) => v !== value)); + } else { + setSelectedValues((prev) => [...prev, value]); + } + } + + function updateSelectedValues(values: any[]) { + setSelectedValues(values); + setHasEdited(true); + } + + return { + selectedValues, + setSelectedValues, + selectSingleValue, + toggleSelectedValue, + updateSelectedValues, + hasEdited, + setHasEdited, + }; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx index a8d0b2556d0c93..96602ae6f12c72 100644 --- a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx @@ -1,24 +1,12 @@ import { Button } from 'antd'; import React from 'react'; import styled from 'styled-components'; -import { - FormPrompt, - PropertyCardinality, - SchemaField, - StdDataType, - SubmitFormPromptInput, -} from '../../../../../../types.generated'; -import SingleSelectInput from './SingleSelectInput'; -import MultiSelectInput from './MultiSelectInput'; +import { FormPrompt, SchemaField, SubmitFormPromptInput } from '../../../../../../types.generated'; import useStructuredPropertyPrompt from './useStructuredPropertyPrompt'; -import StringInput from './StringInput'; -import RichTextInput from './RichTextInput'; -import DateInput from './DateInput'; -import NumberInput from './NumberInput'; -import UrnInput from './UrnInput/UrnInput'; import CompletedPromptAuditStamp from './CompletedPromptAuditStamp'; import { applyOpacity } from '../../../../../shared/styleUtils'; import usePromptCompletionInfo from '../usePromptCompletionInfo'; +import StructuredPropertyInput from '../../../components/styled/StructuredProperty/StructuredPropertyInput'; const PromptWrapper = styled.div<{ displayBulkStyles?: boolean }>` display: flex; @@ -87,7 +75,7 @@ export default function StructuredPropertyPrompt({ optimisticCompletedTimestamp, }: Props) { const { - hasEditedPrompt, + hasEdited, selectedValues, selectSingleValue, toggleSelectedValue, @@ -103,9 +91,9 @@ export default function StructuredPropertyPrompt({ const structuredProperty = prompt.structuredPropertyParams?.structuredProperty; if (!structuredProperty) return null; - const { displayName, description, allowedValues, cardinality, valueType } = structuredProperty.definition; - const showSaveButton = hasEditedPrompt && selectedValues.length > 0; - const showConfirmButton = !hasEditedPrompt && !isComplete && selectedValues.length > 0; + const { displayName, description } = structuredProperty.definition; + const showSaveButton = hasEdited && selectedValues.length > 0; + const showConfirmButton = !hasEdited && !isComplete && selectedValues.length > 0; return ( <> @@ -118,54 +106,16 @@ export default function StructuredPropertyPrompt({ {description && {description}} - {allowedValues && allowedValues.length > 0 && ( - <> - {cardinality === PropertyCardinality.Single && ( - - )} - {cardinality === PropertyCardinality.Multiple && ( - - )} - - )} - {!allowedValues && valueType.info.type === StdDataType.String && ( - - )} - {!allowedValues && valueType.info.type === StdDataType.RichText && ( - - )} - {!allowedValues && valueType.info.type === StdDataType.Date && ( - - )} - {!allowedValues && valueType.info.type === StdDataType.Number && ( - - )} - {!allowedValues && valueType.info.type === StdDataType.Urn && ( - - )} + - {isComplete && !hasEditedPrompt && ( + {isComplete && !hasEdited && ( )} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts index de647e5c717d40..6ad4ab15e04076 100644 --- a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { useEntityContext } from '../../../EntityContext'; import { FormPrompt, FormPromptType, SchemaField, SubmitFormPromptInput } from '../../../../../../types.generated'; import { getInitialValues } from './utils'; @@ -6,6 +6,7 @@ import usePrevious from '../../../../../shared/usePrevious'; import { useGetEntityWithSchema } from '../../../tabs/Dataset/Schema/useGetEntitySchema'; import { FormView, useEntityFormContext } from '../../EntityFormContext'; import { SCHEMA_FIELD_PROMPT_TYPES } from '../../constants'; +import { useEditStructuredProperty } from '../../../components/styled/StructuredProperty/useEditStructuredProperty'; interface Props { prompt: FormPrompt; @@ -17,12 +18,19 @@ export default function useStructuredPropertyPrompt({ prompt, submitResponse, fi const { refetch: refetchSchema } = useGetEntityWithSchema(!SCHEMA_FIELD_PROMPT_TYPES.includes(prompt.type)); const { refetch, entityData } = useEntityContext(); const { selectedPromptId, formView } = useEntityFormContext(); - const [hasEditedPrompt, setHasEditedPrompt] = useState(false); const initialValues = useMemo( () => (formView === FormView.BY_ENTITY ? getInitialValues(prompt, entityData, field) : []), [formView, entityData, prompt, field], ); - const [selectedValues, setSelectedValues] = useState(initialValues || []); + const { + selectedValues, + setSelectedValues, + selectSingleValue, + toggleSelectedValue, + updateSelectedValues, + hasEdited, + setHasEdited, + } = useEditStructuredProperty(); const structuredProperty = prompt.structuredPropertyParams?.structuredProperty; @@ -31,35 +39,15 @@ export default function useStructuredPropertyPrompt({ prompt, submitResponse, fi if (entityData?.urn !== previousEntityUrn) { setSelectedValues(initialValues || []); } - }, [entityData?.urn, previousEntityUrn, initialValues]); + }, [entityData?.urn, previousEntityUrn, initialValues, setSelectedValues]); const previousSelectedPromptId = usePrevious(selectedPromptId); useEffect(() => { if (selectedPromptId !== previousSelectedPromptId) { - setHasEditedPrompt(false); + setHasEdited(false); setSelectedValues(initialValues || []); } - }, [previousSelectedPromptId, selectedPromptId, initialValues]); - - // respond to prompts - function selectSingleValue(value: string | number) { - setHasEditedPrompt(true); - setSelectedValues([value as string]); - } - - function toggleSelectedValue(value: string | number) { - setHasEditedPrompt(true); - if (selectedValues.includes(value)) { - setSelectedValues((prev) => prev.filter((v) => v !== value)); - } else { - setSelectedValues((prev) => [...prev, value]); - } - } - - function updateSelectedValues(values: any[]) { - setSelectedValues(values); - setHasEditedPrompt(true); - } + }, [previousSelectedPromptId, selectedPromptId, initialValues, setSelectedValues, setHasEdited]); // submit structured property prompt function submitStructuredPropertyResponse() { @@ -81,7 +69,7 @@ export default function useStructuredPropertyPrompt({ prompt, submitResponse, fi }, () => { refetch(); - setHasEditedPrompt(false); + setHasEdited(false); if (field) { refetchSchema(); } @@ -90,7 +78,7 @@ export default function useStructuredPropertyPrompt({ prompt, submitResponse, fi } return { - hasEditedPrompt, + hasEdited, selectedValues, selectSingleValue, toggleSelectedValue, diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx new file mode 100644 index 00000000000000..7ff08e38138632 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx @@ -0,0 +1,30 @@ +import { Button } from 'antd'; +import React, { useState } from 'react'; +import { PropertyRow } from '../types'; +import EditStructuredPropertyModal from './EditStructuredPropertyModal'; + +interface Props { + propertyRow: PropertyRow; +} + +export function EditColumn({ propertyRow }: Props) { + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + + if (!propertyRow.structuredProperty) { + return null; + } + + return ( + <> + + setIsEditModalVisible(false)} + /> + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx new file mode 100644 index 00000000000000..73a280031ebd09 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx @@ -0,0 +1,98 @@ +import { Button, Modal, message } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { PropertyRow } from '../types'; +import StructuredPropertyInput from '../../../components/styled/StructuredProperty/StructuredPropertyInput'; +import { PropertyValueInput, StructuredPropertyEntity } from '../../../../../../types.generated'; +import { useUpsertStructuredPropertiesMutation } from '../../../../../../graphql/structuredProperties.generated'; +import { useEditStructuredProperty } from '../../../components/styled/StructuredProperty/useEditStructuredProperty'; +import { useEntityContext, useMutationUrn } from '../../../EntityContext'; +import handleGraphQLError from '../../../../../shared/handleGraphQLError'; + +const Description = styled.div` + font-size: 14px; + margin-bottom: 16px; + margin-top: -8px; +`; + +interface Props { + isOpen: boolean; + propertyRow: PropertyRow; + structuredProperty: StructuredPropertyEntity; + closeModal: () => void; +} + +export default function EditStructuredPropertyModal({ isOpen, propertyRow, structuredProperty, closeModal }: Props) { + const { refetch } = useEntityContext(); + const urn = useMutationUrn(); + const initialValues = propertyRow.values?.map((v) => v.value) || []; + const { selectedValues, selectSingleValue, toggleSelectedValue, updateSelectedValues } = + useEditStructuredProperty(initialValues); + const [upsertStructuredProperties] = useUpsertStructuredPropertiesMutation(); + + function upsertProperties() { + message.loading('Updating...'); + upsertStructuredProperties({ + variables: { + input: { + assetUrn: urn, + structuredPropertyInputParams: [ + { + structuredPropertyUrn: structuredProperty.urn, + values: selectedValues.map((value) => { + if (typeof value === 'string') { + return { stringValue: value as string }; + } + return { numberValue: value as number }; + }) as PropertyValueInput[], + }, + ], + }, + }, + }) + .then(() => { + refetch(); + message.destroy(); + message.success('Successfully updated structured property!'); + closeModal(); + }) + .catch((error) => { + handleGraphQLError({ + error, + defaultMessage: 'Unable to save structured property. Something went wrong.', + }); + closeModal(); + }); + } + + return ( + + + + + } + destroyOnClose + > + {structuredProperty?.definition.description && ( + {structuredProperty.definition.description} + )} + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx index 01d1145877e3b0..b56434fa9c48bf 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx @@ -11,6 +11,7 @@ import NameColumn from './NameColumn'; import TabHeader from './TabHeader'; import useUpdateExpandedRowsFromFilter from './useUpdateExpandedRowsFromFilter'; import { useEntityRegistry } from '../../../../useEntityRegistry'; +import { EditColumn } from './Edit/EditColumn'; const StyledTable = styled(Table)` &&& .ant-table-cell-with-append { @@ -36,6 +37,14 @@ export const PropertiesTab = () => { }, ]; + if (entityData?.privileges?.canEditProperties) { + propertyTableColumns.push({ + title: '', + width: '10%', + render: (propertyRow: PropertyRow) => , + } as any); + } + const { structuredPropertyRows, expandedRowsFromFilter } = useStructuredProperties(entityRegistry, filterText); const customProperties = getFilteredCustomProperties(filterText, entityData) || []; const customPropertyRows = mapCustomPropertiesToPropertyRows(customProperties); diff --git a/datahub-web-react/src/app/lineage/__tests__/constructTree.test.ts b/datahub-web-react/src/app/lineage/__tests__/constructTree.test.ts index 3e2743b5639089..2e41fb9ea07bff 100644 --- a/datahub-web-react/src/app/lineage/__tests__/constructTree.test.ts +++ b/datahub-web-react/src/app/lineage/__tests__/constructTree.test.ts @@ -42,7 +42,7 @@ describe('constructTree', () => { icon: undefined, platform: kafkaPlatform, schemaMetadata: dataset3.schemaMetadata, - canEditLineage: false, + canEditLineage: true, }); }); @@ -399,7 +399,7 @@ describe('constructTree', () => { platform: kafkaPlatform, subtype: undefined, schemaMetadata: updatedDataset6WithLineage.schemaMetadata, - canEditLineage: false, + canEditLineage: true, children: [ { name: 'DataJobInfoName', @@ -413,7 +413,7 @@ describe('constructTree', () => { status: null, platform: airflowPlatform, subtype: undefined, - canEditLineage: false, + canEditLineage: true, }, ], }); diff --git a/datahub-web-react/src/graphql/chart.graphql b/datahub-web-react/src/graphql/chart.graphql index 440ad9cbab869a..04942a08dde7da 100644 --- a/datahub-web-react/src/graphql/chart.graphql +++ b/datahub-web-react/src/graphql/chart.graphql @@ -97,8 +97,7 @@ query getChart($urn: String!) { ...inputFieldsFields } privileges { - canEditLineage - canEditEmbed + ...entityPrivileges } subTypes { typeNames diff --git a/datahub-web-react/src/graphql/container.graphql b/datahub-web-react/src/graphql/container.graphql index 408b05ae9ada85..efeca6da5e6ddb 100644 --- a/datahub-web-react/src/graphql/container.graphql +++ b/datahub-web-react/src/graphql/container.graphql @@ -14,6 +14,9 @@ query getContainer($urn: String!) { value } } + privileges { + ...entityPrivileges + } editableProperties { description } diff --git a/datahub-web-react/src/graphql/dashboard.graphql b/datahub-web-react/src/graphql/dashboard.graphql index eb3fa3e46606cd..68a966a68e00a9 100644 --- a/datahub-web-react/src/graphql/dashboard.graphql +++ b/datahub-web-react/src/graphql/dashboard.graphql @@ -1,6 +1,9 @@ query getDashboard($urn: String!) { dashboard(urn: $urn) { ...dashboardFields + privileges { + ...entityPrivileges + } charts: relationships(input: { types: ["Contains"], direction: OUTGOING, start: 0, count: 100 }) { ...fullRelationshipResults } diff --git a/datahub-web-react/src/graphql/dataFlow.graphql b/datahub-web-react/src/graphql/dataFlow.graphql index bda544e1061049..e4284225e55b9a 100644 --- a/datahub-web-react/src/graphql/dataFlow.graphql +++ b/datahub-web-react/src/graphql/dataFlow.graphql @@ -19,6 +19,9 @@ fragment dataFlowFields on DataFlow { value } } + privileges { + ...entityPrivileges + } editableProperties { description } diff --git a/datahub-web-react/src/graphql/dataJob.graphql b/datahub-web-react/src/graphql/dataJob.graphql index b7275469000343..78247bd460fbb7 100644 --- a/datahub-web-react/src/graphql/dataJob.graphql +++ b/datahub-web-react/src/graphql/dataJob.graphql @@ -1,6 +1,9 @@ query getDataJob($urn: String!) { dataJob(urn: $urn) { ...dataJobFields + privileges { + ...entityPrivileges + } runs(start: 0, count: 20) { count start diff --git a/datahub-web-react/src/graphql/dataProduct.graphql b/datahub-web-react/src/graphql/dataProduct.graphql index dc66fa110d3030..eb053ca9561315 100644 --- a/datahub-web-react/src/graphql/dataProduct.graphql +++ b/datahub-web-react/src/graphql/dataProduct.graphql @@ -1,6 +1,9 @@ query getDataProduct($urn: String!) { dataProduct(urn: $urn) { ...dataProductFields + privileges { + ...entityPrivileges + } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields } diff --git a/datahub-web-react/src/graphql/dataset.graphql b/datahub-web-react/src/graphql/dataset.graphql index 42281501e529de..42c8f0939e9753 100644 --- a/datahub-web-react/src/graphql/dataset.graphql +++ b/datahub-web-react/src/graphql/dataset.graphql @@ -164,9 +164,7 @@ fragment nonSiblingDatasetFields on Dataset { total } privileges { - canEditLineage - canEditEmbed - canEditQueries + ...entityPrivileges } forms { ...formsFields diff --git a/datahub-web-react/src/graphql/domain.graphql b/datahub-web-react/src/graphql/domain.graphql index 02684152103f4b..3897a2ced85b8f 100644 --- a/datahub-web-react/src/graphql/domain.graphql +++ b/datahub-web-react/src/graphql/domain.graphql @@ -13,6 +13,9 @@ query getDomain($urn: String!) { ownership { ...ownershipFields } + privileges { + ...entityPrivileges + } institutionalMemory { elements { url diff --git a/datahub-web-react/src/graphql/fragments.graphql b/datahub-web-react/src/graphql/fragments.graphql index a13f9162602fc1..03283538847f7d 100644 --- a/datahub-web-react/src/graphql/fragments.graphql +++ b/datahub-web-react/src/graphql/fragments.graphql @@ -1393,3 +1393,12 @@ fragment formsFields on Forms { } } } + +fragment entityPrivileges on EntityPrivileges { + canEditLineage + canEditQueries + canEditEmbed + canManageEntity + canManageChildren + canEditProperties +} diff --git a/datahub-web-react/src/graphql/glossaryNode.graphql b/datahub-web-react/src/graphql/glossaryNode.graphql index 58ce29d9f4e0cd..6e232827c23706 100644 --- a/datahub-web-react/src/graphql/glossaryNode.graphql +++ b/datahub-web-react/src/graphql/glossaryNode.graphql @@ -24,8 +24,7 @@ query getGlossaryNode($urn: String!) { ...parentNodesFields } privileges { - canManageEntity - canManageChildren + ...entityPrivileges } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields diff --git a/datahub-web-react/src/graphql/glossaryTerm.graphql b/datahub-web-react/src/graphql/glossaryTerm.graphql index 696883563a6808..f0152e7e8df399 100644 --- a/datahub-web-react/src/graphql/glossaryTerm.graphql +++ b/datahub-web-react/src/graphql/glossaryTerm.graphql @@ -85,7 +85,7 @@ query getGlossaryTerm($urn: String!, $start: Int, $count: Int) { ...deprecationFields } privileges { - canManageEntity + ...entityPrivileges } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields diff --git a/datahub-web-react/src/graphql/group.graphql b/datahub-web-react/src/graphql/group.graphql index 7c47a83451a4ee..ee04489540f9c7 100644 --- a/datahub-web-react/src/graphql/group.graphql +++ b/datahub-web-react/src/graphql/group.graphql @@ -25,6 +25,9 @@ query getGroup($urn: String!, $membersCount: Int!) { email slack } + privileges { + ...entityPrivileges + } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields } diff --git a/datahub-web-react/src/graphql/mlFeature.graphql b/datahub-web-react/src/graphql/mlFeature.graphql index 5ae97587eb626c..d6a75e16b86f17 100644 --- a/datahub-web-react/src/graphql/mlFeature.graphql +++ b/datahub-web-react/src/graphql/mlFeature.graphql @@ -1,6 +1,9 @@ query getMLFeature($urn: String!) { mlFeature(urn: $urn) { ...nonRecursiveMLFeature + privileges { + ...entityPrivileges + } featureTables: relationships(input: { types: ["Contains"], direction: INCOMING, start: 0, count: 100 }) { ...fullRelationshipResults } diff --git a/datahub-web-react/src/graphql/mlFeatureTable.graphql b/datahub-web-react/src/graphql/mlFeatureTable.graphql index cb1dbfa4976018..a6e069c120518a 100644 --- a/datahub-web-react/src/graphql/mlFeatureTable.graphql +++ b/datahub-web-react/src/graphql/mlFeatureTable.graphql @@ -1,6 +1,9 @@ query getMLFeatureTable($urn: String!) { mlFeatureTable(urn: $urn) { ...nonRecursiveMLFeatureTable + privileges { + ...entityPrivileges + } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields } diff --git a/datahub-web-react/src/graphql/mlModel.graphql b/datahub-web-react/src/graphql/mlModel.graphql index d08775e9e4315d..1626bc473213af 100644 --- a/datahub-web-react/src/graphql/mlModel.graphql +++ b/datahub-web-react/src/graphql/mlModel.graphql @@ -18,6 +18,9 @@ query getMLModel($urn: String!) { } } } + privileges { + ...entityPrivileges + } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields } diff --git a/datahub-web-react/src/graphql/mlModelGroup.graphql b/datahub-web-react/src/graphql/mlModelGroup.graphql index 1beec36840964f..8ae049c8c0b1db 100644 --- a/datahub-web-react/src/graphql/mlModelGroup.graphql +++ b/datahub-web-react/src/graphql/mlModelGroup.graphql @@ -21,6 +21,9 @@ query getMLModelGroup($urn: String!) { ) { ...fullRelationshipResults } + privileges { + ...entityPrivileges + } autoRenderAspects: aspects(input: { autoRenderOnly: true }) { ...autoRenderAspectFields } diff --git a/datahub-web-react/src/graphql/mlPrimaryKey.graphql b/datahub-web-react/src/graphql/mlPrimaryKey.graphql index b25d5581d6d306..599c4d7fabcac2 100644 --- a/datahub-web-react/src/graphql/mlPrimaryKey.graphql +++ b/datahub-web-react/src/graphql/mlPrimaryKey.graphql @@ -1,6 +1,9 @@ query getMLPrimaryKey($urn: String!) { mlPrimaryKey(urn: $urn) { ...nonRecursiveMLPrimaryKey + privileges { + ...entityPrivileges + } featureTables: relationships(input: { types: ["KeyedBy"], direction: INCOMING, start: 0, count: 100 }) { ...fullRelationshipResults } diff --git a/datahub-web-react/src/graphql/structuredProperties.graphql b/datahub-web-react/src/graphql/structuredProperties.graphql new file mode 100644 index 00000000000000..60079db3b1cd4b --- /dev/null +++ b/datahub-web-react/src/graphql/structuredProperties.graphql @@ -0,0 +1,7 @@ +mutation upsertStructuredProperties($input: UpsertStructuredPropertiesInput!) { + upsertStructuredProperties(input: $input) { + properties { + ...structuredPropertiesFields + } + } +} diff --git a/datahub-web-react/src/graphql/user.graphql b/datahub-web-react/src/graphql/user.graphql index 6cda57a71487c1..82b8c2da7ffe0b 100644 --- a/datahub-web-react/src/graphql/user.graphql +++ b/datahub-web-react/src/graphql/user.graphql @@ -24,6 +24,9 @@ query getUser($urn: String!, $groupsCount: Int!) { title email } + privileges { + ...entityPrivileges + } globalTags { ...globalTagsFields } diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json index 60464d37c4a6f0..b41f8aee267d3a 100644 --- a/metadata-service/war/src/main/resources/boot/policies.json +++ b/metadata-service/war/src/main/resources/boot/policies.json @@ -234,7 +234,8 @@ "MANAGE_DATA_PRODUCTS", "CREATE_ER_MODEL_RELATIONSHIP_PRIVILEGE", "DELETE_ENTITY", - "ES_EXPLAIN_QUERY_PRIVILEGE" + "ES_EXPLAIN_QUERY_PRIVILEGE", + "EDIT_ENTITY_PROPERTIES" ], "displayName":"Admins - Metadata Policy", "description":"Admins have all metadata privileges.", @@ -311,7 +312,8 @@ "GET_TIMELINE_PRIVILEGE", "PRODUCE_PLATFORM_EVENT_PRIVILEGE", "MANAGE_DATA_PRODUCTS", - "ES_EXPLAIN_QUERY_PRIVILEGE" + "ES_EXPLAIN_QUERY_PRIVILEGE", + "EDIT_ENTITY_PROPERTIES" ], "displayName":"Editors - Metadata Policy", "description":"Editors have all metadata privileges.", @@ -462,7 +464,8 @@ "GET_TIMESERIES_ASPECT_PRIVILEGE", "GET_COUNTS_PRIVILEGE", "MANAGE_DATA_PRODUCTS", - "ES_EXPLAIN_QUERY_PRIVILEGE" + "ES_EXPLAIN_QUERY_PRIVILEGE", + "EDIT_ENTITY_PROPERTIES" ], "displayName":"Asset Owners - Metadata Policy", "description":"Asset Owners have all metadata privileges ONLY for assets they own.", diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java index 0f01f9dfcd5593..2898e171938150 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java @@ -242,6 +242,12 @@ public class PoliciesConfig { "Edit Embedded Content", "The ability to edit the embedded content for an entity."); + public static final Privilege EDIT_ENTITY_PROPERTIES_PRIVILEGE = + Privilege.of( + "EDIT_ENTITY_PROPERTIES", + "Edit Properties", + "The ability to edit the properties for an entity."); + public static final Privilege CREATE_ER_MODEL_RELATIONSHIP_PRIVILEGE = Privilege.of( "CREATE_ENTITY_ER_MODEL_RELATIONSHIP", @@ -263,6 +269,7 @@ public class PoliciesConfig { EDIT_ENTITY_PRIVILEGE, DELETE_ENTITY_PRIVILEGE, VIEW_ENTITY_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE, EDIT_ENTITY_INCIDENTS_PRIVILEGE); // Dataset Privileges @@ -522,7 +529,8 @@ public class PoliciesConfig { EDIT_ENTITY_DOC_LINKS_PRIVILEGE, EDIT_ENTITY_PRIVILEGE, DELETE_ENTITY_PRIVILEGE, - MANAGE_DATA_PRODUCTS_PRIVILEGE)); + MANAGE_DATA_PRODUCTS_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE)); // Data Product Privileges public static final ResourcePrivileges DATA_PRODUCT_PRIVILEGES = @@ -539,7 +547,8 @@ public class PoliciesConfig { DELETE_ENTITY_PRIVILEGE, EDIT_ENTITY_TAGS_PRIVILEGE, EDIT_ENTITY_GLOSSARY_TERMS_PRIVILEGE, - EDIT_ENTITY_DOMAINS_PRIVILEGE)); + EDIT_ENTITY_DOMAINS_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE)); // Glossary Term Privileges public static final ResourcePrivileges GLOSSARY_TERM_PRIVILEGES = @@ -553,7 +562,8 @@ public class PoliciesConfig { EDIT_ENTITY_DOCS_PRIVILEGE, EDIT_ENTITY_DOC_LINKS_PRIVILEGE, EDIT_ENTITY_DEPRECATION_PRIVILEGE, - EDIT_ENTITY_PRIVILEGE)); + EDIT_ENTITY_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE)); // Glossary Node Privileges public static final ResourcePrivileges GLOSSARY_NODE_PRIVILEGES = @@ -569,7 +579,8 @@ public class PoliciesConfig { EDIT_ENTITY_DEPRECATION_PRIVILEGE, EDIT_ENTITY_PRIVILEGE, MANAGE_GLOSSARY_CHILDREN_PRIVILEGE, - MANAGE_ALL_GLOSSARY_CHILDREN_PRIVILEGE)); + MANAGE_ALL_GLOSSARY_CHILDREN_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE)); // Group Privileges public static final ResourcePrivileges CORP_GROUP_PRIVILEGES = @@ -583,7 +594,8 @@ public class PoliciesConfig { EDIT_GROUP_MEMBERS_PRIVILEGE, EDIT_CONTACT_INFO_PRIVILEGE, EDIT_ENTITY_DOCS_PRIVILEGE, - EDIT_ENTITY_PRIVILEGE)); + EDIT_ENTITY_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE)); // User Privileges public static final ResourcePrivileges CORP_USER_PRIVILEGES = @@ -595,7 +607,8 @@ public class PoliciesConfig { VIEW_ENTITY_PAGE_PRIVILEGE, EDIT_CONTACT_INFO_PRIVILEGE, EDIT_USER_PROFILE_PRIVILEGE, - EDIT_ENTITY_PRIVILEGE)); + EDIT_ENTITY_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE)); // ERModelRelationship Privileges public static final ResourcePrivileges ER_MODEL_RELATIONSHIP_PRIVILEGES =