From 93befda8cf20921f6f06c75e77ebe50bb9c7af23 Mon Sep 17 00:00:00 2001 From: John Joyce Date: Thu, 17 Feb 2022 22:47:59 -0800 Subject: [PATCH] feat(groups): Adding editable group properties in the backend (#4166) --- .../datahub/graphql/GmsGraphQLEngine.java | 5 +- .../group/AddGroupMembersResolver.java | 23 ++- .../group/RemoveGroupMembersResolver.java | 23 ++- .../resolvers/group/RemoveGroupResolver.java | 3 +- .../resolvers/mutate/DescriptionUtils.java | 24 +++ .../mutate/UpdateDescriptionResolver.java | 27 +++- .../UpdateUserStatusResolver.java | 2 +- .../types/corpgroup/CorpGroupType.java | 101 ++++++++++++- .../CorpGroupEditablePropertiesMapper.java | 30 ++++ .../corpgroup/mappers/CorpGroupMapper.java | 7 +- .../graphql/types/corpuser/CorpUserType.java | 91 ++++++++---- .../src/main/resources/entity.graphql | 51 ++++++- .../identity/CorpGroupEditableInfo.pdl | 35 +++++ .../com/linkedin/identity/CorpGroupInfo.pdl | 10 +- .../src/main/resources/entity-registry.yml | 6 + .../war/src/main/resources/boot/policies.json | 25 ++++ .../java/com/linkedin/metadata/Constants.java | 1 + .../authorization/PoliciesConfig.java | 56 ++++++- smoke-test/test_e2e.py | 139 ++++++++++++++++++ 19 files changed, 611 insertions(+), 48 deletions(-) rename datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/{group => user}/UpdateUserStatusResolver.java (97%) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupEditablePropertiesMapper.java create mode 100644 metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupEditableInfo.pdl 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 0bd8c791da..f174d50239 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 @@ -61,7 +61,7 @@ import com.linkedin.datahub.graphql.resolvers.group.EntityCountsResolver; import com.linkedin.datahub.graphql.resolvers.group.ListGroupsResolver; import com.linkedin.datahub.graphql.resolvers.group.RemoveGroupMembersResolver; import com.linkedin.datahub.graphql.resolvers.group.RemoveGroupResolver; -import com.linkedin.datahub.graphql.resolvers.group.UpdateUserStatusResolver; +import com.linkedin.datahub.graphql.resolvers.user.UpdateUserStatusResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CancelIngestionExecutionRequestResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CreateIngestionExecutionRequestResolver; import com.linkedin.datahub.graphql.resolvers.ingest.secret.CreateSecretResolver; @@ -595,7 +595,8 @@ public class GmsGraphQLEngine { .dataFetcher("updateDashboard", new AuthenticatedResolver<>(new MutableTypeResolver<>(dashboardType))) .dataFetcher("updateDataJob", new AuthenticatedResolver<>(new MutableTypeResolver<>(dataJobType))) .dataFetcher("updateDataFlow", new AuthenticatedResolver<>(new MutableTypeResolver<>(dataFlowType))) - .dataFetcher("updateCorpUserProperties", new AuthenticatedResolver<>(new MutableTypeResolver<>(corpUserType))) + .dataFetcher("updateCorpUserProperties", new MutableTypeResolver<>(corpUserType)) + .dataFetcher("updateCorpGroupProperties", new MutableTypeResolver<>(corpGroupType)) .dataFetcher("addTag", new AuthenticatedResolver<>(new AddTagResolver(entityService))) .dataFetcher("removeTag", new AuthenticatedResolver<>(new RemoveTagResolver(entityService))) .dataFetcher("addTerm", new AuthenticatedResolver<>(new AddTermResolver(entityService))) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/AddGroupMembersResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/AddGroupMembersResolver.java index f9d27c58a0..ef198b214f 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/AddGroupMembersResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/AddGroupMembersResolver.java @@ -1,9 +1,12 @@ package com.linkedin.datahub.graphql.resolvers.group; +import com.google.common.collect.ImmutableList; import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup; import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.exception.DataHubGraphQLErrorCode; import com.linkedin.datahub.graphql.exception.DataHubGraphQLException; @@ -12,6 +15,7 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.identity.GroupMembership; +import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.utils.GenericAspectUtils; import com.linkedin.mxe.MetadataChangeProposal; import graphql.schema.DataFetcher; @@ -20,6 +24,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; +import static com.linkedin.datahub.graphql.resolvers.AuthUtils.*; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; import static com.linkedin.metadata.Constants.*; @@ -38,10 +43,10 @@ public class AddGroupMembersResolver implements DataFetcher get(final DataFetchingEnvironment environment) throws Exception { + final AddGroupMembersInput input = bindArgument(environment.getArgument("input"), AddGroupMembersInput.class); final QueryContext context = environment.getContext(); - if (AuthorizationUtils.canManageUsersAndGroups(context)) { - final AddGroupMembersInput input = bindArgument(environment.getArgument("input"), AddGroupMembersInput.class); + if (isAuthorized(input, context)) { final String groupUrnStr = input.getGroupUrn(); final List userUrnStrs = input.getUserUrns(); @@ -60,6 +65,20 @@ public class AddGroupMembersResolver implements DataFetcher get(final DataFetchingEnvironment environment) throws Exception { + final RemoveGroupMembersInput input = bindArgument(environment.getArgument("input"), RemoveGroupMembersInput.class); final QueryContext context = environment.getContext(); - if (AuthorizationUtils.canManageUsersAndGroups(context)) { - final RemoveGroupMembersInput input = bindArgument(environment.getArgument("input"), RemoveGroupMembersInput.class); + if (isAuthorized(input, context)) { final Urn groupUrn = Urn.createFromString(input.getGroupUrn()); final Set userUrns = input.getUserUrns().stream().map(UrnUtils::getUrn).collect(Collectors.toSet()); final Map entityResponseMap = _entityClient.batchGetV2(CORP_USER_ENTITY_NAME, @@ -72,4 +77,18 @@ public class RemoveGroupMembersResolver implements DataFetcher> { @@ -28,6 +28,7 @@ public class RemoveGroupResolver implements DataFetcher { try { + // TODO: Remove all dangling references to this group. _entityClient.deleteEntity(urn, context.getAuthentication()); return true; } catch (Exception e) { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java index 704b1bd2c1..2a0dbd2e51 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java @@ -11,6 +11,7 @@ import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup; import com.linkedin.datahub.graphql.generated.SubResourceType; import com.linkedin.domain.DomainProperties; import com.linkedin.glossary.GlossaryTermInfo; +import com.linkedin.identity.CorpGroupEditableInfo; import com.linkedin.metadata.Constants; import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.entity.EntityService; @@ -97,6 +98,19 @@ public class DescriptionUtils { persistAspect(resourceUrn, Constants.TAG_PROPERTIES_ASPECT_NAME, tagProperties, actor, entityService); } + public static void updateCorpGroupDescription( + String newDescription, + Urn resourceUrn, + Urn actor, + EntityService entityService + ) { + CorpGroupEditableInfo corpGroupEditableInfo = + (CorpGroupEditableInfo) getAspectFromEntity( + resourceUrn.toString(), Constants.CORP_GROUP_EDITABLE_INFO_ASPECT_NAME, entityService, new CorpGroupEditableInfo()); + corpGroupEditableInfo.setDescription(newDescription); + persistAspect(resourceUrn, Constants.CORP_GROUP_EDITABLE_INFO_ASPECT_NAME, corpGroupEditableInfo, actor, entityService); + } + public static void updateGlossaryTermDescription( String newDescription, Urn resourceUrn, @@ -161,6 +175,16 @@ public class DescriptionUtils { return true; } + public static Boolean validateCorpGroupInput( + Urn corpUserUrn, + EntityService entityService + ) { + if (!entityService.exists(corpUserUrn)) { + throw new IllegalArgumentException(String.format("Failed to update %s. %s does not exist.", corpUserUrn, corpUserUrn)); + } + return true; + } + public static boolean isAuthorizedToUpdateFieldDescription(@Nonnull QueryContext context, Urn targetUrn) { final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of( ALL_PRIVILEGES_GROUP, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java index 966a9bde73..77b586bced 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java @@ -15,7 +15,6 @@ import lombok.extern.slf4j.Slf4j; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; - @Slf4j @RequiredArgsConstructor public class UpdateDescriptionResolver implements DataFetcher> { @@ -37,6 +36,8 @@ public class UpdateDescriptionResolver implements DataFetcher updateCorpGroupDescription(Urn targetUrn, DescriptionUpdateInput input, QueryContext context) { + return CompletableFuture.supplyAsync(() -> { + + if (!DescriptionUtils.isAuthorizedToUpdateDescription(context, targetUrn)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + DescriptionUtils.validateCorpGroupInput(targetUrn, _entityService); + + try { + Urn actor = CorpuserUrn.createFromString(context.getActorUrn()); + DescriptionUtils.updateCorpGroupDescription( + input.getDescription(), + targetUrn, + actor, + _entityService); + return true; + } catch (Exception e) { + log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage()); + throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e); + } + }); + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/UpdateUserStatusResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/UpdateUserStatusResolver.java similarity index 97% rename from datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/UpdateUserStatusResolver.java rename to datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/UpdateUserStatusResolver.java index 498d3685e5..e03c4ff968 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/UpdateUserStatusResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/UpdateUserStatusResolver.java @@ -1,4 +1,4 @@ -package com.linkedin.datahub.graphql.resolvers.group; +package com.linkedin.datahub.graphql.resolvers.user; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/CorpGroupType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/CorpGroupType.java index 9701cfb6b7..813100db55 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/CorpGroupType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/CorpGroupType.java @@ -1,21 +1,35 @@ package com.linkedin.datahub.graphql.types.corpgroup; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.RecordTemplate; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.AutoCompleteResults; import com.linkedin.datahub.graphql.generated.CorpGroup; +import com.linkedin.datahub.graphql.generated.CorpGroupUpdateInput; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.SearchResults; +import com.linkedin.datahub.graphql.types.MutableType; import com.linkedin.datahub.graphql.types.SearchableEntityType; import com.linkedin.datahub.graphql.types.corpgroup.mappers.CorpGroupMapper; import com.linkedin.datahub.graphql.types.mappers.AutoCompleteResultsMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.identity.CorpGroupEditableInfo; +import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.query.AutoCompleteResult; import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.utils.GenericAspectUtils; +import com.linkedin.mxe.MetadataChangeProposal; import graphql.execution.DataFetcherResult; import java.util.ArrayList; import java.util.Collections; @@ -28,8 +42,7 @@ import javax.annotation.Nullable; import static com.linkedin.metadata.Constants.*; - -public class CorpGroupType implements SearchableEntityType { +public class CorpGroupType implements SearchableEntityType, MutableType { private final EntityClient _entityClient; @@ -42,6 +55,10 @@ public class CorpGroupType implements SearchableEntityType { return CorpGroup.class; } + public Class inputClass() { + return CorpGroupUpdateInput.class; + } + @Override public EntityType type() { return EntityType.CORP_GROUP; @@ -93,4 +110,84 @@ public class CorpGroupType implements SearchableEntityType { context.getAuthentication()); return AutoCompleteResultsMapper.map(result); } + + @Override + public CorpGroup update(@Nonnull String urn, @Nonnull CorpGroupUpdateInput input, @Nonnull QueryContext context) throws Exception { + if (isAuthorizedToUpdate(urn, input, context)) { + // Get existing editable info to merge with + Urn groupUrn = Urn.createFromString(urn); + Map gmsResponse = + _entityClient.batchGetV2(CORP_GROUP_ENTITY_NAME, ImmutableSet.of(groupUrn), ImmutableSet.of( + CORP_GROUP_EDITABLE_INFO_ASPECT_NAME), + context.getAuthentication()); + + CorpGroupEditableInfo existingCorpGroupEditableInfo = null; + if (gmsResponse.containsKey(groupUrn) && gmsResponse.get(groupUrn).getAspects().containsKey(CORP_GROUP_EDITABLE_INFO_ASPECT_NAME)) { + existingCorpGroupEditableInfo = new CorpGroupEditableInfo(gmsResponse.get(groupUrn).getAspects() + .get(CORP_GROUP_EDITABLE_INFO_ASPECT_NAME).getValue().data()); + } + + // Create the MCP + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityUrn(Urn.createFromString(urn)); + proposal.setEntityType(CORP_GROUP_ENTITY_NAME); + proposal.setAspectName(CORP_GROUP_EDITABLE_INFO_ASPECT_NAME); + proposal.setAspect( + GenericAspectUtils.serializeAspect(mapCorpGroupEditableInfo(input, existingCorpGroupEditableInfo))); + proposal.setChangeType(ChangeType.UPSERT); + _entityClient.ingestProposal(proposal, context.getAuthentication()); + + return load(urn, context).getData(); + } + throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + private boolean isAuthorizedToUpdate(String urn, CorpGroupUpdateInput input, QueryContext context) { + // Decide whether the current principal should be allowed to update the Dataset. + final DisjunctivePrivilegeGroup orPrivilegeGroups = getAuthorizedPrivileges(input); + return AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getAuthentication().getActor().toUrnStr(), + PoliciesConfig.CORP_GROUP_PRIVILEGES.getResourceType(), + urn, + orPrivilegeGroups); + } + + private DisjunctivePrivilegeGroup getAuthorizedPrivileges(final CorpGroupUpdateInput updateInput) { + final ConjunctivePrivilegeGroup allPrivilegesGroup = new ConjunctivePrivilegeGroup(ImmutableList.of( + PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType() + )); + + List specificPrivileges = new ArrayList<>(); + if (updateInput.getDescription() != null) { + // Requires the Update Docs privilege. + specificPrivileges.add(PoliciesConfig.EDIT_ENTITY_DOCS_PRIVILEGE.getType()); + } else if (updateInput.getSlack() != null || updateInput.getEmail() != null) { + // Requires the Update Contact info privilege. + specificPrivileges.add(PoliciesConfig.EDIT_CONTACT_INFO_PRIVILEGE.getType()); + } + + final ConjunctivePrivilegeGroup specificPrivilegeGroup = new ConjunctivePrivilegeGroup(specificPrivileges); + + // If you either have all entity privileges, or have the specific privileges required, you are authorized. + return new DisjunctivePrivilegeGroup(ImmutableList.of( + allPrivilegesGroup, + specificPrivilegeGroup + )); + } + + private RecordTemplate mapCorpGroupEditableInfo(CorpGroupUpdateInput input, @Nullable CorpGroupEditableInfo existing) { + CorpGroupEditableInfo result = existing != null ? existing : new CorpGroupEditableInfo(); + + if (input.getDescription() != null) { + result.setDescription(input.getDescription()); + } + if (input.getSlack() != null) { + result.setSlack(input.getSlack()); + } + if (input.getEmail() != null) { + result.setEmail(input.getEmail()); + } + return result; + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupEditablePropertiesMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupEditablePropertiesMapper.java new file mode 100644 index 0000000000..f476794bc5 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupEditablePropertiesMapper.java @@ -0,0 +1,30 @@ +package com.linkedin.datahub.graphql.types.corpgroup.mappers; + +import com.linkedin.data.template.GetMode; +import com.linkedin.datahub.graphql.generated.CorpGroupEditableProperties; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; + +import javax.annotation.Nonnull; + +/** + * Maps Pegasus {@link RecordTemplate} objects to objects conforming to the GQL schema. + * + * To be replaced by auto-generated mappers implementations + */ +public class CorpGroupEditablePropertiesMapper implements ModelMapper { + + public static final CorpGroupEditablePropertiesMapper INSTANCE = new CorpGroupEditablePropertiesMapper(); + + public static CorpGroupEditableProperties map(@Nonnull final com.linkedin.identity.CorpGroupEditableInfo corpGroupEditableInfo) { + return INSTANCE.apply(corpGroupEditableInfo); + } + + @Override + public CorpGroupEditableProperties apply(@Nonnull final com.linkedin.identity.CorpGroupEditableInfo corpGroupEditableInfo) { + final CorpGroupEditableProperties result = new CorpGroupEditableProperties(); + result.setDescription(corpGroupEditableInfo.getDescription(GetMode.DEFAULT)); + result.setSlack(corpGroupEditableInfo.getSlack(GetMode.DEFAULT)); + result.setEmail(corpGroupEditableInfo.getEmail(GetMode.DEFAULT)); + return result; + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java index dbce338ecb..3b353be749 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java @@ -7,6 +7,7 @@ import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.identity.CorpGroupEditableInfo; import com.linkedin.identity.CorpGroupInfo; import com.linkedin.metadata.key.CorpGroupKey; import javax.annotation.Nonnull; @@ -36,7 +37,7 @@ public class CorpGroupMapper implements ModelMapper { MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); mappingHelper.mapToResult(CORP_GROUP_KEY_ASPECT_NAME, this::mapCorpGroupKey); mappingHelper.mapToResult(CORP_GROUP_INFO_ASPECT_NAME, this::mapCorpGroupInfo); - + mappingHelper.mapToResult(CORP_GROUP_EDITABLE_INFO_ASPECT_NAME, this::mapCorpGroupEditableInfo); return mappingHelper.getResult(); } @@ -50,4 +51,8 @@ public class CorpGroupMapper implements ModelMapper { corpGroup.setProperties(CorpGroupPropertiesMapper.map(corpGroupInfo)); corpGroup.setInfo(CorpGroupInfoMapper.map(corpGroupInfo)); } + + private void mapCorpGroupEditableInfo(@Nonnull CorpGroup corpGroup, @Nonnull DataMap dataMap) { + corpGroup.setEditableProperties(CorpGroupEditablePropertiesMapper.map(new CorpGroupEditableInfo(dataMap))); + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java index 2638a15fc2..7eaa67d912 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java @@ -1,12 +1,16 @@ package com.linkedin.datahub.graphql.types.corpuser; +import com.google.common.collect.ImmutableList; import com.linkedin.common.url.Url; -import com.linkedin.common.urn.CorpuserUrn; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringArray; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.AutoCompleteResults; import com.linkedin.datahub.graphql.generated.CorpUser; import com.linkedin.datahub.graphql.generated.CorpUserUpdateInput; @@ -23,12 +27,12 @@ import com.linkedin.entity.client.EntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.identity.CorpUserEditableInfo; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.query.AutoCompleteResult; import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.utils.GenericAspectUtils; import com.linkedin.mxe.MetadataChangeProposal; import graphql.execution.DataFetcherResult; -import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -106,37 +110,70 @@ public class CorpUserType implements SearchableEntityType, MutableType return AutoCompleteResultsMapper.map(result); } - private CorpuserUrn getCorpUserUrn(final String urnStr) { - try { - return CorpuserUrn.createFromString(urnStr); - } catch (URISyntaxException e) { - throw new RuntimeException(String.format("Failed to retrieve user with urn %s, invalid urn", urnStr)); - } - } - public Class inputClass() { return CorpUserUpdateInput.class; } @Override public CorpUser update(@Nonnull String urn, @Nonnull CorpUserUpdateInput input, @Nonnull QueryContext context) throws Exception { - final CorpuserUrn actor = CorpuserUrn.createFromString(context.getAuthentication().getActor().toUrnStr()); + if (isAuthorizedToUpdate(urn, input, context)) { + // Get existing editable info to merge with + Optional existingCorpUserEditableInfo = + _entityClient.getVersionedAspect(urn, Constants.CORP_USER_EDITABLE_INFO_NAME, 0L, CorpUserEditableInfo.class, + context.getAuthentication()); - // Get existing editable info to merge with - Optional existingCorpUserEditableInfo = - _entityClient.getVersionedAspect(urn, Constants.CORP_USER_EDITABLE_INFO_NAME, 0L, CorpUserEditableInfo.class, - context.getAuthentication()); + // Create the MCP + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityUrn(Urn.createFromString(urn)); + proposal.setEntityType(Constants.CORP_USER_ENTITY_NAME); + proposal.setAspectName(Constants.CORP_USER_EDITABLE_INFO_NAME); + proposal.setAspect(GenericAspectUtils.serializeAspect(mapCorpUserEditableInfo(input, existingCorpUserEditableInfo))); + proposal.setChangeType(ChangeType.UPSERT); + _entityClient.ingestProposal(proposal, context.getAuthentication()); - // Create the MCP - final MetadataChangeProposal proposal = new MetadataChangeProposal(); - proposal.setEntityUrn(Urn.createFromString(urn)); - proposal.setEntityType(Constants.CORP_USER_ENTITY_NAME); - proposal.setAspectName(Constants.CORP_USER_EDITABLE_INFO_NAME); - proposal.setAspect(GenericAspectUtils.serializeAspect(mapCorpUserEditableInfo(input, existingCorpUserEditableInfo))); - proposal.setChangeType(ChangeType.UPSERT); - _entityClient.ingestProposal(proposal, context.getAuthentication()); + return load(urn, context).getData(); + } + throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); + } - return load(urn, context).getData(); + private boolean isAuthorizedToUpdate(String urn, CorpUserUpdateInput input, QueryContext context) { + // Decide whether the current principal should be allowed to update the Dataset. + final DisjunctivePrivilegeGroup orPrivilegeGroups = getAuthorizedPrivileges(input); + + // Either the updating actor is the user, or the actor has privileges to update the user information. + return context.getActorUrn().equals(urn) || AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getAuthentication().getActor().toUrnStr(), + PoliciesConfig.CORP_GROUP_PRIVILEGES.getResourceType(), + urn, + orPrivilegeGroups); + } + + private DisjunctivePrivilegeGroup getAuthorizedPrivileges(final CorpUserUpdateInput updateInput) { + final ConjunctivePrivilegeGroup allPrivilegesGroup = new ConjunctivePrivilegeGroup(ImmutableList.of( + PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType() + )); + + List specificPrivileges = new ArrayList<>(); + if (updateInput.getSlack() != null + || updateInput.getEmail() != null + || updateInput.getPhone() != null) { + specificPrivileges.add(PoliciesConfig.EDIT_CONTACT_INFO_PRIVILEGE.getType()); + } else if (updateInput.getAboutMe() != null + || updateInput.getDisplayName() != null + || updateInput.getPictureLink() != null + || updateInput.getTeams() != null + || updateInput.getTitle() != null) { + specificPrivileges.add(PoliciesConfig.EDIT_USER_PROFILE_PRIVILEGE.getType()); + } + + final ConjunctivePrivilegeGroup specificPrivilegeGroup = new ConjunctivePrivilegeGroup(specificPrivileges); + + // If you either have all entity privileges, or have the specific privileges required, you are authorized. + return new DisjunctivePrivilegeGroup(ImmutableList.of( + allPrivilegesGroup, + specificPrivilegeGroup + )); } private RecordTemplate mapCorpUserEditableInfo(CorpUserUpdateInput input, Optional existing) { @@ -159,6 +196,9 @@ public class CorpUserType implements SearchableEntityType, MutableType if (input.getTeams() != null) { result.setTeams(new StringArray(input.getTeams())); } + if (input.getTitle() != null) { + result.setTitle(input.getTitle()); + } if (input.getPhone() != null) { result.setPhone(input.getPhone()); } @@ -168,9 +208,6 @@ public class CorpUserType implements SearchableEntityType, MutableType if (input.getEmail() != null) { result.setEmail(input.getEmail()); } - if (input.getTitle() != null) { - result.setTitle(input.getTitle()); - } return result; } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 9be2ac696c..910ab9b7b7 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -277,6 +277,11 @@ type Mutation { Update a particular Corp User's editable properties """ updateCorpUserProperties(urn: String!, input: CorpUserUpdateInput!): CorpUser + + """ + Update a particular Corp Group's editable properties + """ + updateCorpGroupProperties(urn: String!, input: CorpGroupUpdateInput!): CorpGroup } """ @@ -2328,7 +2333,6 @@ type CorpUserEditableProperties { email: String } - """ Arguments provided to update a CorpUser Entity """ @@ -2403,6 +2407,11 @@ type CorpGroup implements Entity { """ properties: CorpGroupProperties + """ + Additional read write properties about the group + """ + editableProperties: CorpGroupEditableProperties + """ Edges extending from this entity """ @@ -2474,6 +2483,46 @@ type CorpGroupProperties { email: String } +""" +Additional read write properties about a group +""" +type CorpGroupEditableProperties { + """ + DataHub description of the group + """ + description: String + + """ + Slack handle for the group + """ + slack: String + + """ + Email address for the group + """ + email: String +} + +""" +Arguments provided to update a CorpGroup Entity +""" +input CorpGroupUpdateInput { + """ + DataHub description of the group + """ + description: String + + """ + Slack handle for the group + """ + slack: String + + """ + Email address for the group + """ + email: String +} + """ An owner of a Metadata Entity, either a user or group """ diff --git a/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupEditableInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupEditableInfo.pdl new file mode 100644 index 0000000000..ec67b233b6 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupEditableInfo.pdl @@ -0,0 +1,35 @@ +namespace com.linkedin.identity + +import com.linkedin.common.Url + +/** + * Group information that can be edited from UI + */ +@Aspect = { + "name": "corpGroupEditableInfo" +} +record CorpGroupEditableInfo { + /** + * A description of the group + */ + @Searchable = { + "fieldType": "TEXT", + "fieldName": "editedDescription", + } + description: optional string + + /** + * A URL which points to a picture which user wants to set as the photo for the group + */ + pictureLink: Url = "https://raw.githubusercontent.com/linkedin/datahub/master/datahub-web-react/src/images/default_avatar.png" + + /** + * Slack handle for the group + */ + slack: optional string + + /** + * Email address to contact the group + */ + email: optional string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupInfo.pdl index 1e13dbf38a..2e1564bd68 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupInfo.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/identity/CorpGroupInfo.pdl @@ -5,7 +5,7 @@ import com.linkedin.common.CorpuserUrn import com.linkedin.common.EmailAddress /** - * group of corpUser, it may contains nested group + * Information about a Corp Group ingested from a third party source */ @Aspect = { "name": "corpGroupInfo" @@ -14,7 +14,7 @@ import com.linkedin.common.EmailAddress record CorpGroupInfo { /** - * The name to use when displaying the group. + * The name of the group. */ @Searchable = { "fieldType": "TEXT_PARTIAL" @@ -28,6 +28,7 @@ record CorpGroupInfo { /** * owners of this group + * Deprecated! Replaced by Ownership aspect. */ @Relationship = { "/*": { @@ -35,10 +36,12 @@ record CorpGroupInfo { "entityTypes": [ "corpUser" ] } } + @deprecated admins: array[CorpuserUrn] /** * List of ldap urn in this group. + * Deprecated! Replaced by GroupMembership aspect. */ @Relationship = { "/*": { @@ -46,10 +49,12 @@ record CorpGroupInfo { "entityTypes": [ "corpUser" ] } } + @deprecated members: array[CorpuserUrn] /** * List of groups in this group. + * Deprecated! This field is unused. */ @Relationship = { "/*": { @@ -57,6 +62,7 @@ record CorpGroupInfo { "entityTypes": [ "corpGroup" ] } } + @deprecated groups: array[CorpGroupUrn] /** diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index a555325867..edb1ca6a24 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -107,3 +107,9 @@ entities: aspects: - assertionInfo - dataPlatformInstance + - name: corpGroup + doc: CorpGroup represents an identity of a group of users in the enterprise. + keyAspect: corpGroupKey + aspects: + - corpGroupEditableInfo + - ownership \ No newline at end of file diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json index b001640e27..86bf73796a 100644 --- a/metadata-service/war/src/main/resources/boot/policies.json +++ b/metadata-service/war/src/main/resources/boot/policies.json @@ -246,5 +246,30 @@ "type":"METADATA", "editable":false } + }, + { + "urn": "urn:li:dataHubPolicy:10", + "info": { + "actors":{ + "resourceOwners":false, + "allUsers":false, + "allGroups":false, + "users":[ + "urn:li:corpuser:datahub" + ] + }, + "privileges":[ + "EDIT_ENTITY" + ], + "displayName":"Root User - Edit All Groups", + "resources":{ + "type":"corpGroup", + "allResources":true + }, + "description":"Grants full edit privileges for Groups to root 'datahub' root user.", + "state":"ACTIVE", + "type":"METADATA", + "editable":false + } } ] diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java b/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java index 9edbf4fe69..aedda130be 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -63,6 +63,7 @@ public class Constants { // Group public static final String CORP_GROUP_KEY_ASPECT_NAME = "corpGroupKey"; public static final String CORP_GROUP_INFO_ASPECT_NAME = "corpGroupInfo"; + public static final String CORP_GROUP_EDITABLE_INFO_ASPECT_NAME = "corpGroupEditableInfo"; // Dataset public static final String DATASET_KEY_ASPECT_NAME = "datasetKey"; 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 7436146b5d..4f05988b46 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 @@ -84,17 +84,17 @@ public class PoliciesConfig { public static final Privilege EDIT_ENTITY_OWNERS_PRIVILEGE = Privilege.of( "EDIT_ENTITY_OWNERS", "Edit Owners", - "The ability to add and remove owners of an asset."); + "The ability to add and remove owners of an entity."); public static final Privilege EDIT_ENTITY_DOCS_PRIVILEGE = Privilege.of( "EDIT_ENTITY_DOCS", - "Edit Documentation", - "The ability to edit documentation about an asset."); + "Edit Description", + "The ability to edit the description (documentation) of an entity."); public static final Privilege EDIT_ENTITY_DOC_LINKS_PRIVILEGE = Privilege.of( "EDIT_ENTITY_DOC_LINKS", "Edit Links", - "The ability to edit links associated with an asset."); + "The ability to edit links associated with an entity."); public static final Privilege EDIT_ENTITY_STATUS_PRIVILEGE = Privilege.of( "EDIT_ENTITY_STATUS", @@ -114,7 +114,7 @@ public class PoliciesConfig { public static final Privilege EDIT_ENTITY_PRIVILEGE = Privilege.of( "EDIT_ENTITY", "Edit All", - "The ability to edit any information about an asset. Super user privileges."); + "The ability to edit any information about an entity. Super user privileges."); public static final List COMMON_ENTITY_PRIVILEGES = ImmutableList.of( EDIT_ENTITY_TAGS_PRIVILEGE, @@ -153,6 +153,24 @@ public class PoliciesConfig { "Edit Tag Color", "The ability to change the color of a Tag."); + // Group Privileges + public static final Privilege EDIT_GROUP_MEMBERS_PRIVILEGE = Privilege.of( + "EDIT_GROUP_MEMBERS", + "Edit Group Members", + "The ability to add and remove members to a group."); + + // User Privileges + public static final Privilege EDIT_USER_PROFILE_PRIVILEGE = Privilege.of( + "EDIT_USER_PROFILE", + "Edit User Profile", + "The ability to change the user's profile including display name, bio, title, profile image, etc."); + + // User + Group Privileges + public static final Privilege EDIT_CONTACT_INFO_PRIVILEGE = Privilege.of( + "EDIT_CONTACT_INFO", + "Edit Contact Information", + "The ability to change the contact information such as email & chat handles."); + public static final ResourcePrivileges DATASET_PRIVILEGES = ResourcePrivileges.of( "dataset", "Datasets", @@ -232,6 +250,30 @@ public class PoliciesConfig { EDIT_ENTITY_PRIVILEGE) ); + // Group Privileges + public static final ResourcePrivileges CORP_GROUP_PRIVILEGES = ResourcePrivileges.of( + "corpGroup", + "Groups", + "Groups on DataHub", + ImmutableList.of( + EDIT_ENTITY_OWNERS_PRIVILEGE, + EDIT_GROUP_MEMBERS_PRIVILEGE, + EDIT_CONTACT_INFO_PRIVILEGE, + EDIT_ENTITY_DOCS_PRIVILEGE, + EDIT_ENTITY_PRIVILEGE) + ); + + // User Privileges + public static final ResourcePrivileges CORP_USER_PRIVILEGES = ResourcePrivileges.of( + "corpuser", + "Users", + "Users on DataHub", + ImmutableList.of( + EDIT_CONTACT_INFO_PRIVILEGE, + EDIT_USER_PROFILE_PRIVILEGE, + EDIT_ENTITY_PRIVILEGE) + ); + public static final List RESOURCE_PRIVILEGES = ImmutableList.of( DATASET_PRIVILEGES, DASHBOARD_PRIVILEGES, @@ -241,7 +283,9 @@ public class PoliciesConfig { TAG_PRIVILEGES, CONTAINER_PRIVILEGES, DOMAIN_PRIVILEGES, - GLOSSARY_TERM_PRIVILEGES + GLOSSARY_TERM_PRIVILEGES, + CORP_GROUP_PRIVILEGES, + CORP_USER_PRIVILEGES ); @Data diff --git a/smoke-test/test_e2e.py b/smoke-test/test_e2e.py index 53d0dc1fd9..aa976e9014 100644 --- a/smoke-test/test_e2e.py +++ b/smoke-test/test_e2e.py @@ -1073,6 +1073,144 @@ def test_add_remove_members_from_group(frontend_session): assert res_data["data"]["corpUser"]["relationships"]["total"] == 0 +@pytest.mark.dependency( + depends=["test_healthchecks", "test_run_ingestion"] +) +def test_update_corp_group_properties(frontend_session): + + group_urn = "urn:li:corpGroup:bfoo" + + # Update Corp Group Description + json = { + "query": """mutation updateCorpGroupProperties($urn: String!, $input: CorpGroupUpdateInput!) {\n + updateCorpGroupProperties(urn: $urn, input: $input) { urn } }""", + "variables": { + "urn": group_urn, + "input": { + "description": "My test description", + "slack": "test_group_slack", + "email": "test_group_email@email.com", + }, + }, + } + + response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + print(res_data) + assert "error" not in res_data + assert res_data["data"]["updateCorpGroupProperties"] is not None + + # Verify the description has been updated + json = { + "query": """query corpGroup($urn: String!) {\n + corpGroup(urn: $urn) {\n + urn\n + editableProperties {\n + description\n + slack\n + email\n + }\n + }\n + }""", + "variables": {"urn": group_urn}, + } + response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + + assert res_data + assert "error" not in res_data + assert res_data["data"] + assert res_data["data"]["corpGroup"] + assert res_data["data"]["corpGroup"]["editableProperties"] + assert res_data["data"]["corpGroup"]["editableProperties"] == { + "description": "My test description", + "slack": "test_group_slack", + "email": "test_group_email@email.com" + } + + # Reset the editable properties + json = { + "query": """mutation updateCorpGroupProperties($urn: String!, $input: UpdateCorpGroupPropertiesInput!) {\n + updateCorpGroupProperties(urn: $urn, input: $input) }""", + "variables": { + "urn": group_urn, + "input": { + "description": "", + "slack": "", + "email": "" + }, + }, + } + + response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=json) + response.raise_for_status() + +@pytest.mark.dependency( + depends=["test_healthchecks", "test_run_ingestion", "test_update_corp_group_properties"] +) +def test_update_corp_group_description(frontend_session): + + group_urn = "urn:li:corpGroup:bfoo" + + # Update Corp Group Description + json = { + "query": """mutation updateDescription($input: DescriptionUpdateInput!) {\n + updateDescription(input: $input) }""", + "variables": { + "input": { + "description": "My test description", + "resourceUrn": group_urn + }, + }, + } + + response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + print(res_data) + assert "error" not in res_data + assert res_data["data"]["updateDescription"] is True + + # Verify the description has been updated + json = { + "query": """query corpGroup($urn: String!) {\n + corpGroup(urn: $urn) {\n + urn\n + editableProperties {\n + description\n + }\n + }\n + }""", + "variables": {"urn": group_urn}, + } + response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + + assert res_data + assert "error" not in res_data + assert res_data["data"] + assert res_data["data"]["corpGroup"] + assert res_data["data"]["corpGroup"]["editableProperties"] + assert res_data["data"]["corpGroup"]["editableProperties"]["description"] == "My test description" + + # Reset Corp Group Description + json = { + "query": """mutation updateDescription($input: DescriptionUpdateInput!) {\n + updateDescription(input: $input) }""", + "variables": { + "input": { + "description": "", + "resourceUrn": group_urn + }, + }, + } + + response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=json) + response.raise_for_status() + @pytest.mark.dependency( depends=[ "test_healthchecks", @@ -1108,6 +1246,7 @@ def test_remove_user(frontend_session): res_data = response.json() assert res_data + assert "error" not in res_data assert res_data["data"] assert res_data["data"]["corpUser"] assert res_data["data"]["corpUser"]["properties"] is None