feat(groups): Adding editable group properties in the backend (#4166)

This commit is contained in:
John Joyce 2022-02-17 22:47:59 -08:00 committed by GitHub
parent 8167cbd432
commit 93befda8cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 611 additions and 48 deletions

View File

@ -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)))

View File

@ -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<CompletableFuture<Bo
@Override
public CompletableFuture<Boolean> 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<String> userUrnStrs = input.getUserUrns();
@ -60,6 +65,20 @@ public class AddGroupMembersResolver implements DataFetcher<CompletableFuture<Bo
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
}
private boolean isAuthorized(AddGroupMembersInput input, QueryContext context) {
final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of(
ALL_PRIVILEGES_GROUP,
new ConjunctivePrivilegeGroup(ImmutableList.of(PoliciesConfig.EDIT_GROUP_MEMBERS_PRIVILEGE.getType()))
));
return AuthorizationUtils.isAuthorized(
context.getAuthorizer(),
context.getActorUrn(),
CORP_GROUP_ENTITY_NAME,
input.getGroupUrn(),
orPrivilegeGroups);
}
private void addUserToGroup(final String userUrnStr, final String groupUrnStr, final QueryContext context) {
try {
// First, fetch user's group membership aspect.

View File

@ -1,15 +1,19 @@
package com.linkedin.datahub.graphql.resolvers.group;
import com.google.common.collect.ImmutableList;
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.authorization.ConjunctivePrivilegeGroup;
import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.RemoveGroupMembersInput;
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.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.linkedin.datahub.graphql.resolvers.AuthUtils.*;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
import static com.linkedin.metadata.Constants.*;
@ -35,10 +40,10 @@ public class RemoveGroupMembersResolver implements DataFetcher<CompletableFuture
@Override
public CompletableFuture<Boolean> 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<Urn> userUrns = input.getUserUrns().stream().map(UrnUtils::getUrn).collect(Collectors.toSet());
final Map<Urn, EntityResponse> entityResponseMap = _entityClient.batchGetV2(CORP_USER_ENTITY_NAME,
@ -72,4 +77,18 @@ public class RemoveGroupMembersResolver implements DataFetcher<CompletableFuture
}
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
}
private boolean isAuthorized(RemoveGroupMembersInput input, QueryContext context) {
final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of(
ALL_PRIVILEGES_GROUP,
new ConjunctivePrivilegeGroup(ImmutableList.of(PoliciesConfig.EDIT_GROUP_MEMBERS_PRIVILEGE.getType()))
));
return AuthorizationUtils.isAuthorized(
context.getAuthorizer(),
context.getActorUrn(),
CORP_GROUP_ENTITY_NAME,
input.getGroupUrn(),
orPrivilegeGroups);
}
}

View File

@ -10,7 +10,7 @@ import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
/**
* Resolver responsible for hard deleting a particular DataHub Corp User
* Resolver responsible for hard deleting a particular DataHub Corp Group
*/
public class RemoveGroupResolver implements DataFetcher<CompletableFuture<Boolean>> {
@ -28,6 +28,7 @@ public class RemoveGroupResolver implements DataFetcher<CompletableFuture<Boolea
final Urn urn = Urn.createFromString(groupUrn);
return CompletableFuture.supplyAsync(() -> {
try {
// TODO: Remove all dangling references to this group.
_entityClient.deleteEntity(urn, context.getAuthentication());
return true;
} catch (Exception e) {

View File

@ -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,

View File

@ -15,7 +15,6 @@ import lombok.extern.slf4j.Slf4j;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
@Slf4j
@RequiredArgsConstructor
public class UpdateDescriptionResolver implements DataFetcher<CompletableFuture<Boolean>> {
@ -37,6 +36,8 @@ public class UpdateDescriptionResolver implements DataFetcher<CompletableFuture<
return updateGlossaryTermDescription(targetUrn, input, environment.getContext());
case Constants.TAG_ENTITY_NAME:
return updateTagDescription(targetUrn, input, environment.getContext());
case Constants.CORP_GROUP_ENTITY_NAME:
return updateCorpGroupDescription(targetUrn, input, environment.getContext());
default:
throw new RuntimeException(
String.format("Failed to update description. Unsupported resource type %s provided.", targetUrn));
@ -167,4 +168,28 @@ public class UpdateDescriptionResolver implements DataFetcher<CompletableFuture<
}
});
}
private CompletableFuture<Boolean> 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);
}
});
}
}

View File

@ -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;

View File

@ -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<CorpGroup> {
public class CorpGroupType implements SearchableEntityType<CorpGroup>, MutableType<CorpGroupUpdateInput, CorpGroup> {
private final EntityClient _entityClient;
@ -42,6 +55,10 @@ public class CorpGroupType implements SearchableEntityType<CorpGroup> {
return CorpGroup.class;
}
public Class<CorpGroupUpdateInput> inputClass() {
return CorpGroupUpdateInput.class;
}
@Override
public EntityType type() {
return EntityType.CORP_GROUP;
@ -93,4 +110,84 @@ public class CorpGroupType implements SearchableEntityType<CorpGroup> {
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<Urn, EntityResponse> 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<String> 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;
}
}

View File

@ -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<com.linkedin.identity.CorpGroupEditableInfo, CorpGroupEditableProperties> {
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;
}
}

View File

@ -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<EntityResponse, CorpGroup> {
MappingHelper<CorpGroup> 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<EntityResponse, CorpGroup> {
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)));
}
}

View File

@ -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<CorpUser>, 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<CorpUserUpdateInput> 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<CorpUserEditableInfo> existingCorpUserEditableInfo =
_entityClient.getVersionedAspect(urn, Constants.CORP_USER_EDITABLE_INFO_NAME, 0L, CorpUserEditableInfo.class,
context.getAuthentication());
// Get existing editable info to merge with
Optional<CorpUserEditableInfo> 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<String> 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<CorpUserEditableInfo> existing) {
@ -159,6 +196,9 @@ public class CorpUserType implements SearchableEntityType<CorpUser>, 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<CorpUser>, MutableType
if (input.getEmail() != null) {
result.setEmail(input.getEmail());
}
if (input.getTitle() != null) {
result.setTitle(input.getTitle());
}
return result;
}

View File

@ -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
"""

View File

@ -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
}

View File

@ -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]
/**

View File

@ -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

View File

@ -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
}
}
]

View File

@ -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";

View File

@ -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<Privilege> 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<ResourcePrivileges> 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

View File

@ -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