diff --git a/datahub-frontend/app/client/AuthServiceClient.java b/datahub-frontend/app/client/AuthServiceClient.java index c7b443b1ecef4..9b6b869e4ae3f 100644 --- a/datahub-frontend/app/client/AuthServiceClient.java +++ b/datahub-frontend/app/client/AuthServiceClient.java @@ -123,7 +123,7 @@ public boolean signUp(@Nonnull final String userUrn, @Nonnull final String fullN String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort, SIGN_UP_ENDPOINT)); - // Build JSON request to verify credentials for a native user. + // Build JSON request to sign up a native user. final ObjectMapper objectMapper = new ObjectMapper(); final ObjectNode objectNode = objectMapper.createObjectNode(); objectNode.put(USER_URN_FIELD, userUrn); @@ -150,7 +150,7 @@ public boolean signUp(@Nonnull final String userUrn, @Nonnull final String fullN response.getEntity().toString())); } } catch (Exception e) { - throw new RuntimeException("Failed to create user", e); + throw new RuntimeException(String.format("Failed to create user %s", userUrn), e); } finally { try { httpClient.close(); diff --git a/datahub-frontend/app/controllers/AuthenticationController.java b/datahub-frontend/app/controllers/AuthenticationController.java index 36b943b8235a0..6b49f288bd862 100644 --- a/datahub-frontend/app/controllers/AuthenticationController.java +++ b/datahub-frontend/app/controllers/AuthenticationController.java @@ -150,8 +150,7 @@ public Result logIn(Http.Request request) { } /** - * Sign up a native user based on a name, email, title, and password. The invite token must match the global invite - * token stored for the DataHub instance. + * Sign up a native user based on a name, email, title, and password. The invite token must match an existing invite token. * */ @Nonnull @@ -199,7 +198,7 @@ public Result signUp(Http.Request request) { final Urn userUrn = new CorpuserUrn(email); final String userUrnString = userUrn.toString(); - boolean isNativeUserCreated = _authClient.signUp(userUrnString, fullName, email, title, password, inviteToken); + _authClient.signUp(userUrnString, fullName, email, title, password, inviteToken); final String accessToken = _authClient.generateSessionTokenForUser(userUrn.getId()); return ok().withSession(createSessionMap(userUrnString, accessToken)) .withCookies(Http.Cookie.builder(ACTOR, userUrnString) @@ -209,7 +208,7 @@ public Result signUp(Http.Request request) { } /** - * Create a native user based on a name, email, and password. + * Reset a native user's credentials based on a username, old password, and new password. * */ @Nonnull @@ -245,9 +244,7 @@ public Result resetNativeUserCredentials(Http.Request request) { final Urn userUrn = new CorpuserUrn(email); final String userUrnString = userUrn.toString(); - boolean areNativeUserCredentialsReset = - _authClient.resetNativeUserCredentials(userUrnString, password, resetToken); - _logger.debug(String.format("Are native user credentials reset: %b", areNativeUserCredentialsReset)); + _authClient.resetNativeUserCredentials(userUrnString, password, resetToken); final String accessToken = _authClient.generateSessionTokenForUser(userUrn.getId()); return ok().withSession(createSessionMap(userUrnString, accessToken)) .withCookies(Http.Cookie.builder(ACTOR, userUrnString) @@ -288,7 +285,6 @@ private String encodeRedirectUri(final String redirectUri) { } private boolean tryLogin(String username, String password) { - JsonNode invalidCredsJson = Json.newObject().put("message", "Invalid Credentials"); boolean loginSucceeded = false; // First try jaas login, if enabled 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 15f3bb8e2797a..c292c0cd3acc3 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 @@ -2,9 +2,11 @@ import com.datahub.authentication.AuthenticationConfiguration; import com.datahub.authentication.group.GroupService; +import com.datahub.authentication.invite.InviteTokenService; import com.datahub.authentication.token.StatefulTokenService; import com.datahub.authentication.user.NativeUserService; import com.datahub.authorization.AuthorizationConfiguration; +import com.datahub.authorization.role.RoleService; import com.google.common.collect.ImmutableList; import com.linkedin.common.VersionedUrn; import com.linkedin.common.urn.Urn; @@ -174,7 +176,10 @@ import com.linkedin.datahub.graphql.resolvers.policy.ListPoliciesResolver; import com.linkedin.datahub.graphql.resolvers.policy.UpsertPolicyResolver; import com.linkedin.datahub.graphql.resolvers.recommendation.ListRecommendationsResolver; +import com.linkedin.datahub.graphql.resolvers.role.AcceptRoleResolver; import com.linkedin.datahub.graphql.resolvers.role.BatchAssignRoleResolver; +import com.linkedin.datahub.graphql.resolvers.role.CreateInviteTokenResolver; +import com.linkedin.datahub.graphql.resolvers.role.GetInviteTokenResolver; import com.linkedin.datahub.graphql.resolvers.role.ListRolesResolver; import com.linkedin.datahub.graphql.resolvers.search.AutoCompleteForMultipleResolver; import com.linkedin.datahub.graphql.resolvers.search.AutoCompleteResolver; @@ -197,9 +202,7 @@ import com.linkedin.datahub.graphql.resolvers.type.PlatformSchemaUnionTypeResolver; import com.linkedin.datahub.graphql.resolvers.type.ResultsTypeResolver; import com.linkedin.datahub.graphql.resolvers.type.TimeSeriesAspectInterfaceTypeResolver; -import com.linkedin.datahub.graphql.resolvers.user.CreateNativeUserInviteTokenResolver; import com.linkedin.datahub.graphql.resolvers.user.CreateNativeUserResetTokenResolver; -import com.linkedin.datahub.graphql.resolvers.user.GetNativeUserInviteTokenResolver; import com.linkedin.datahub.graphql.resolvers.user.ListUsersResolver; import com.linkedin.datahub.graphql.resolvers.user.RemoveUserResolver; import com.linkedin.datahub.graphql.resolvers.user.UpdateUserStatusResolver; @@ -305,6 +308,8 @@ public class GmsGraphQLEngine { private final TimelineService timelineService; private final NativeUserService nativeUserService; private final GroupService groupService; + private final RoleService roleService; + private final InviteTokenService inviteTokenService; private final FeatureFlags featureFlags; @@ -369,25 +374,19 @@ public class GmsGraphQLEngine { */ public final List> browsableTypes; - public GmsGraphQLEngine( - final EntityClient entityClient, - final GraphClient graphClient, - final UsageClient usageClient, - final AnalyticsService analyticsService, - final EntityService entityService, - final RecommendationsService recommendationsService, - final StatefulTokenService statefulTokenService, - final TimeseriesAspectService timeseriesAspectService, - final EntityRegistry entityRegistry, - final SecretService secretService, - final NativeUserService nativeUserService, final IngestionConfiguration ingestionConfiguration, + public GmsGraphQLEngine(final EntityClient entityClient, final GraphClient graphClient, + final UsageClient usageClient, final AnalyticsService analyticsService, final EntityService entityService, + final RecommendationsService recommendationsService, final StatefulTokenService statefulTokenService, + final TimeseriesAspectService timeseriesAspectService, final EntityRegistry entityRegistry, + final SecretService secretService, final NativeUserService nativeUserService, + final IngestionConfiguration ingestionConfiguration, final AuthenticationConfiguration authenticationConfiguration, final AuthorizationConfiguration authorizationConfiguration, final GitVersion gitVersion, final TimelineService timelineService, final boolean supportsImpactAnalysis, final VisualConfiguration visualConfiguration, final TelemetryConfiguration telemetryConfiguration, final TestsConfiguration testsConfiguration, final DatahubConfiguration datahubConfiguration, - final SiblingGraphService siblingGraphService, final GroupService groupService, - final FeatureFlags featureFlags) { + final SiblingGraphService siblingGraphService, final GroupService groupService, final RoleService roleService, + final InviteTokenService inviteTokenService, final FeatureFlags featureFlags) { this.entityClient = entityClient; this.graphClient = graphClient; @@ -406,6 +405,8 @@ public GmsGraphQLEngine( this.timelineService = timelineService; this.nativeUserService = nativeUserService; this.groupService = groupService; + this.roleService = roleService; + this.inviteTokenService = inviteTokenService; this.ingestionConfiguration = Objects.requireNonNull(ingestionConfiguration); this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration); @@ -671,10 +672,10 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("getRootGlossaryTerms", new GetRootGlossaryTermsResolver(this.entityClient)) .dataFetcher("getRootGlossaryNodes", new GetRootGlossaryNodesResolver(this.entityClient)) .dataFetcher("entityExists", new EntityExistsResolver(this.entityService)) - .dataFetcher("getNativeUserInviteToken", new GetNativeUserInviteTokenResolver(this.nativeUserService)) .dataFetcher("entity", getEntityResolver()) .dataFetcher("entities", getEntitiesResolver()) .dataFetcher("listRoles", new ListRolesResolver(this.entityClient)) + .dataFetcher("getInviteToken", new GetInviteTokenResolver(this.inviteTokenService)) ); } @@ -790,12 +791,13 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("updateName", new UpdateNameResolver(entityService)) .dataFetcher("addRelatedTerms", new AddRelatedTermsResolver(this.entityService)) .dataFetcher("removeRelatedTerms", new RemoveRelatedTermsResolver(this.entityService)) - .dataFetcher("createNativeUserInviteToken", new CreateNativeUserInviteTokenResolver(this.nativeUserService)) .dataFetcher("createNativeUserResetToken", new CreateNativeUserResetTokenResolver(this.nativeUserService)) .dataFetcher("batchUpdateSoftDeleted", new BatchUpdateSoftDeletedResolver(this.entityService)) .dataFetcher("updateUserSetting", new UpdateUserSettingResolver(this.entityService)) .dataFetcher("rollbackIngestion", new RollbackIngestionResolver(this.entityClient)) - .dataFetcher("batchAssignRole", new BatchAssignRoleResolver(this.entityClient)) + .dataFetcher("batchAssignRole", new BatchAssignRoleResolver(this.roleService)) + .dataFetcher("createInviteToken", new CreateInviteTokenResolver(this.inviteTokenService)) + .dataFetcher("acceptRole", new AcceptRoleResolver(this.roleService, this.inviteTokenService)) ); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolver.java new file mode 100644 index 0000000000000..3f013f05e73a7 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolver.java @@ -0,0 +1,51 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +import com.datahub.authorization.role.RoleService; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AcceptRoleInput; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + + +@Slf4j + +@RequiredArgsConstructor +public class AcceptRoleResolver implements DataFetcher> { + private final RoleService _roleService; + private final InviteTokenService _inviteTokenService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + + final AcceptRoleInput input = bindArgument(environment.getArgument("input"), AcceptRoleInput.class); + final String inviteTokenStr = input.getInviteToken(); + final Authentication authentication = context.getAuthentication(); + + return CompletableFuture.supplyAsync(() -> { + try { + Urn inviteTokenUrn = _inviteTokenService.getInviteTokenUrn(inviteTokenStr); + if (!_inviteTokenService.isInviteTokenValid(inviteTokenUrn, authentication)) { + throw new RuntimeException(String.format("Invite token %s is invalid", inviteTokenStr)); + } + + Urn roleUrn = _inviteTokenService.getInviteTokenRole(inviteTokenUrn, authentication); + if (roleUrn != null) { + _roleService.assignRoleToActor(authentication.getActor().toUrnStr(), roleUrn, authentication); + } + + return true; + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to accept role using invite token %s", inviteTokenStr), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolver.java index 048add76bc13a..68b5b630a521a 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolver.java @@ -1,20 +1,13 @@ package com.linkedin.datahub.graphql.resolvers.role; import com.datahub.authentication.Authentication; -import com.linkedin.common.UrnArray; +import com.datahub.authorization.role.RoleService; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.BatchAssignRoleInput; -import com.linkedin.entity.client.EntityClient; -import com.linkedin.events.metadata.ChangeType; -import com.linkedin.identity.RoleMembership; -import com.linkedin.metadata.utils.GenericRecordUtils; -import com.linkedin.mxe.MetadataChangeProposal; -import com.linkedin.r2.RemoteInvocationException; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; -import java.net.URISyntaxException; import java.util.List; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; @@ -22,13 +15,12 @@ import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; -import static com.linkedin.metadata.Constants.*; @Slf4j @RequiredArgsConstructor public class BatchAssignRoleResolver implements DataFetcher> { - private final EntityClient _entityClient; + private final RoleService _roleService; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -46,13 +38,13 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw return CompletableFuture.supplyAsync(() -> { try { Urn roleUrn = Urn.createFromString(roleUrnStr); - if (!_entityClient.exists(roleUrn, authentication)) { + if (!_roleService.exists(roleUrn, authentication)) { throw new RuntimeException(String.format("Role %s does not exist", roleUrnStr)); } actors.forEach(actor -> { try { - assignRoleToActor(actor, roleUrn, authentication); + _roleService.assignRoleToActor(actor, roleUrn, authentication); } catch (Exception e) { log.warn( String.format("Failed to assign role %s to actor %s. Skipping actor assignment", roleUrnStr, actor), e); @@ -64,26 +56,4 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw } }); } - - private void assignRoleToActor(String actor, Urn roleUrn, Authentication authentication) - throws URISyntaxException, RemoteInvocationException { - Urn actorUrn = Urn.createFromString(actor); - if (!_entityClient.exists(actorUrn, authentication)) { - log.warn(String.format("Failed to assign role %s to actor %s, actor does not exist. Skipping actor assignment", - roleUrn.toString(), actor)); - return; - } - - RoleMembership roleMembership = new RoleMembership(); - roleMembership.setRoles(new UrnArray(roleUrn)); - - // Finally, create the MetadataChangeProposal. - final MetadataChangeProposal proposal = new MetadataChangeProposal(); - proposal.setEntityUrn(actorUrn); - proposal.setEntityType(CORP_USER_ENTITY_NAME); - proposal.setAspectName(ROLE_MEMBERSHIP_ASPECT_NAME); - proposal.setAspect(GenericRecordUtils.serializeAspect(roleMembership)); - proposal.setChangeType(ChangeType.UPSERT); - _entityClient.ingestProposal(proposal, authentication); - } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolver.java new file mode 100644 index 0000000000000..6bdf52e2f89f1 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolver.java @@ -0,0 +1,44 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.CreateInviteTokenInput; +import com.linkedin.datahub.graphql.generated.InviteToken; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class CreateInviteTokenResolver implements DataFetcher> { + private final InviteTokenService _inviteTokenService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + if (!canManagePolicies(context)) { + throw new AuthorizationException( + "Unauthorized to create invite tokens. Please contact your DataHub administrator if this needs corrective action."); + } + + final CreateInviteTokenInput input = bindArgument(environment.getArgument("input"), CreateInviteTokenInput.class); + final String roleUrnStr = input.getRoleUrn(); + final Authentication authentication = context.getAuthentication(); + + return CompletableFuture.supplyAsync(() -> { + try { + return new InviteToken(_inviteTokenService.getInviteToken(roleUrnStr, true, authentication)); + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to create invite token for role %s", roleUrnStr), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolver.java new file mode 100644 index 0000000000000..0b0cbbb7ba473 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolver.java @@ -0,0 +1,44 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.GetInviteTokenInput; +import com.linkedin.datahub.graphql.generated.InviteToken; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class GetInviteTokenResolver implements DataFetcher> { + private final InviteTokenService _inviteTokenService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + if (!canManagePolicies(context)) { + throw new AuthorizationException( + "Unauthorized to get invite tokens. Please contact your DataHub administrator if this needs corrective action."); + } + + final GetInviteTokenInput input = bindArgument(environment.getArgument("input"), GetInviteTokenInput.class); + final String roleUrnStr = input.getRoleUrn(); + final Authentication authentication = context.getAuthentication(); + + return CompletableFuture.supplyAsync(() -> { + try { + return new InviteToken(_inviteTokenService.getInviteToken(roleUrnStr, false, authentication)); + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to get invite token for role %s", roleUrnStr), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/ListRolesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/ListRolesResolver.java index cafef8fec1c2f..4d8a21b52815b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/ListRolesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/ListRolesResolver.java @@ -15,6 +15,7 @@ import graphql.schema.DataFetchingEnvironment; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -75,6 +76,9 @@ public CompletableFuture get(final DataFetchingEnvironment envi } private List mapEntitiesToRoles(final Collection entities) { - return entities.stream().map(DataHubRoleMapper::map).collect(Collectors.toList()); + return entities.stream() + .map(DataHubRoleMapper::map) + .sorted(Comparator.comparing(DataHubRole::getName)) + .collect(Collectors.toList()); } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolver.java deleted file mode 100644 index 9f8a5ef0e87e4..0000000000000 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolver.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.linkedin.datahub.graphql.resolvers.user; - -import com.datahub.authentication.user.NativeUserService; -import com.linkedin.datahub.graphql.QueryContext; -import com.linkedin.datahub.graphql.exception.AuthorizationException; -import com.linkedin.datahub.graphql.generated.InviteToken; -import graphql.schema.DataFetcher; -import graphql.schema.DataFetchingEnvironment; -import java.util.concurrent.CompletableFuture; - -import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; - -/** - * Resolver responsible for creating an invite token that Admins can share with prospective users to create native - * user accounts. - */ -public class CreateNativeUserInviteTokenResolver implements DataFetcher> { - private final NativeUserService _nativeUserService; - - public CreateNativeUserInviteTokenResolver(final NativeUserService nativeUserService) { - _nativeUserService = nativeUserService; - } - - @Override - public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { - final QueryContext context = environment.getContext(); - return CompletableFuture.supplyAsync(() -> { - if (!canManageUserCredentials(context)) { - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); - } - - try { - String inviteToken = _nativeUserService.generateNativeUserInviteToken(context.getAuthentication()); - return new InviteToken(inviteToken); - } catch (Exception e) { - throw new RuntimeException("Failed to generate new invite token"); - } - }); - } -} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolver.java deleted file mode 100644 index 1d1a329c790b3..0000000000000 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolver.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.linkedin.datahub.graphql.resolvers.user; - -import com.datahub.authentication.user.NativeUserService; -import com.linkedin.datahub.graphql.QueryContext; -import com.linkedin.datahub.graphql.exception.AuthorizationException; -import com.linkedin.datahub.graphql.generated.InviteToken; -import graphql.schema.DataFetcher; -import graphql.schema.DataFetchingEnvironment; -import java.util.concurrent.CompletableFuture; - -import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; - -/** - * Resolver responsible for getting an existing invite token that Admins can share with prospective users to create - * native user accounts. If the invite token does not already exist, this resolver will create a new one. - */ -public class GetNativeUserInviteTokenResolver implements DataFetcher> { - private final NativeUserService _nativeUserService; - - public GetNativeUserInviteTokenResolver(final NativeUserService nativeUserService) { - _nativeUserService = nativeUserService; - } - - @Override - public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { - final QueryContext context = environment.getContext(); - - return CompletableFuture.supplyAsync(() -> { - if (!canManageUserCredentials(context)) { - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); - } - - try { - String inviteToken = _nativeUserService.getNativeUserInviteToken(context.getAuthentication()); - return new InviteToken(inviteToken); - } catch (Exception e) { - throw new RuntimeException("Failed to generate new invite token"); - } - }); - } -} diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 4c5eb89218b53..8a231eaa2854e 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -164,11 +164,6 @@ type Query { """ entityExists(urn: String!): Boolean - """ - Gets the current invite token. If the optional regenerate param is set to true, generate a new invite token. - """ - getNativeUserInviteToken: InviteToken - """ Gets an entity based on its urn """ @@ -183,6 +178,11 @@ type Query { List all DataHub Roles """ listRoles(input: ListRolesInput!): ListRolesResult + + """ + Get invite token + """ + getInviteToken(input: GetInviteTokenInput!): InviteToken } """ @@ -489,11 +489,6 @@ type Mutation { """ removeRelatedTerms(input: RelatedTermsInput!): Boolean - """ - Generates an invite token that can be shared with prospective users to create their accounts. - """ - createNativeUserInviteToken: InviteToken - """ Generates a token that can be shared with existing native users to reset their credentials. """ @@ -513,6 +508,16 @@ type Mutation { Batch assign roles to users """ batchAssignRole(input: BatchAssignRoleInput!): Boolean + + """ + Accept role using invite token + """ + acceptRole(input: AcceptRoleInput!): Boolean + + """ + Create invite token + """ + createInviteToken(input: CreateInviteTokenInput!): InviteToken } """ @@ -9345,3 +9350,33 @@ type DataHubRole implements Entity { """ description: String! } + +""" +Input provided when getting an invite token +""" +input GetInviteTokenInput { + """ + The urn of the role to get the invite token for + """ + roleUrn: String +} + +""" +Input provided when creating an invite token +""" +input CreateInviteTokenInput { + """ + The urn of the role to create the invite token for + """ + roleUrn: String +} + +""" +Input provided when accepting a DataHub role using an invite token +""" +input AcceptRoleInput { + """ + The token needed to accept the role + """ + inviteToken: String! +} \ No newline at end of file diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolverTest.java new file mode 100644 index 0000000000000..8d9a288bc6aa6 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolverTest.java @@ -0,0 +1,102 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Actor; +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +import com.datahub.authorization.role.RoleService; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AcceptRoleInput; +import graphql.schema.DataFetchingEnvironment; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class AcceptRoleResolverTest { + private static final String INVITE_TOKEN_URN_STRING = "urn:li:inviteToken:admin-invite-token"; + private static final String ROLE_URN_STRING = "urn:li:dataHubRole:Admin"; + private static final String ACTOR_URN_STRING = "urn:li:corpuser:user"; + private static final String INVITE_TOKEN_STRING = "inviteToken"; + private Urn roleUrn; + private Urn inviteTokenUrn; + private RoleService _roleService; + private InviteTokenService _inviteTokenService; + private AcceptRoleResolver _resolver; + private DataFetchingEnvironment _dataFetchingEnvironment; + private Authentication _authentication; + + @BeforeMethod + public void setupTest() throws Exception { + roleUrn = Urn.createFromString(ROLE_URN_STRING); + inviteTokenUrn = Urn.createFromString(INVITE_TOKEN_URN_STRING); + _roleService = mock(RoleService.class); + _inviteTokenService = mock(InviteTokenService.class); + _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); + _authentication = mock(Authentication.class); + + _resolver = new AcceptRoleResolver(_roleService, _inviteTokenService); + } + + @Test + public void testNotAuthorizedFails() { + QueryContext mockContext = getMockDenyContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + + assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); + } + + @Test + public void testInvalidInviteToken() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + when(mockContext.getAuthentication()).thenReturn(_authentication); + when(_inviteTokenService.isInviteTokenValid(eq(inviteTokenUrn), eq(_authentication))).thenReturn(false); + + AcceptRoleInput input = new AcceptRoleInput(); + input.setInviteToken(INVITE_TOKEN_STRING); + when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); + + assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); + } + + @Test + public void testNoRoleUrn() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + when(mockContext.getAuthentication()).thenReturn(_authentication); + when(_inviteTokenService.getInviteTokenUrn(eq(INVITE_TOKEN_STRING))).thenReturn(inviteTokenUrn); + when(_inviteTokenService.isInviteTokenValid(eq(inviteTokenUrn), eq(_authentication))).thenReturn(true); + when(_inviteTokenService.getInviteTokenRole(eq(inviteTokenUrn), eq(_authentication))).thenReturn(null); + + AcceptRoleInput input = new AcceptRoleInput(); + input.setInviteToken(INVITE_TOKEN_STRING); + when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); + + assertTrue(_resolver.get(_dataFetchingEnvironment).join()); + verify(_roleService, never()).assignRoleToActor(any(), any(), any()); + } + + @Test + public void testAssignRolePasses() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + when(mockContext.getAuthentication()).thenReturn(_authentication); + when(_inviteTokenService.getInviteTokenUrn(eq(INVITE_TOKEN_STRING))).thenReturn(inviteTokenUrn); + when(_inviteTokenService.isInviteTokenValid(eq(inviteTokenUrn), eq(_authentication))).thenReturn(true); + when(_inviteTokenService.getInviteTokenRole(eq(inviteTokenUrn), eq(_authentication))).thenReturn(roleUrn); + Actor actor = mock(Actor.class); + when(_authentication.getActor()).thenReturn(actor); + when(actor.toUrnStr()).thenReturn(ACTOR_URN_STRING); + + AcceptRoleInput input = new AcceptRoleInput(); + input.setInviteToken(INVITE_TOKEN_STRING); + when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); + + assertTrue(_resolver.get(_dataFetchingEnvironment).join()); + verify(_roleService, times(1)).assignRoleToActor(any(), any(), any()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolverTest.java index 837558135b530..61764dc392ba0 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/BatchAssignRoleResolverTest.java @@ -1,10 +1,10 @@ package com.linkedin.datahub.graphql.resolvers.role; import com.datahub.authentication.Authentication; +import com.datahub.authorization.role.RoleService; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.BatchAssignRoleInput; -import com.linkedin.entity.client.EntityClient; import graphql.schema.DataFetchingEnvironment; import java.util.ArrayList; import java.util.List; @@ -20,19 +20,20 @@ public class BatchAssignRoleResolverTest { private static final String ROLE_URN_STRING = "urn:li:dataHubRole:Admin"; private static final String FIRST_ACTOR_URN_STRING = "urn:li:corpuser:foo"; private static final String SECOND_ACTOR_URN_STRING = "urn:li:corpuser:bar"; - - private EntityClient _entityClient; + private Urn roleUrn; + private RoleService _roleService; private BatchAssignRoleResolver _resolver; private DataFetchingEnvironment _dataFetchingEnvironment; private Authentication _authentication; @BeforeMethod - public void setupTest() { - _entityClient = mock(EntityClient.class); + public void setupTest() throws Exception { + roleUrn = Urn.createFromString(ROLE_URN_STRING); + _roleService = mock(RoleService.class); _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); _authentication = mock(Authentication.class); - _resolver = new BatchAssignRoleResolver(_entityClient); + _resolver = new BatchAssignRoleResolver(_roleService); } @Test @@ -55,7 +56,7 @@ public void testRoleDoesNotExistFails() throws Exception { actors.add(FIRST_ACTOR_URN_STRING); input.setActors(actors); when(_dataFetchingEnvironment.getArgument("input")).thenReturn(input); - when(_entityClient.exists(eq(Urn.createFromString(ROLE_URN_STRING)), eq(_authentication))).thenReturn(false); + when(_roleService.exists(eq(roleUrn), eq(_authentication))).thenReturn(false); assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); } @@ -73,14 +74,11 @@ public void testSomeActorsExist() throws Exception { actors.add(SECOND_ACTOR_URN_STRING); input.setActors(actors); when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); - when(_entityClient.exists(eq(Urn.createFromString(ROLE_URN_STRING)), eq(_authentication))).thenReturn(true); - when(_entityClient.exists(eq(Urn.createFromString(FIRST_ACTOR_URN_STRING)), eq(_authentication))).thenReturn(true); - when(_entityClient.exists(eq(Urn.createFromString(SECOND_ACTOR_URN_STRING)), eq(_authentication))).thenReturn( - false); + doThrow(RuntimeException.class).when(_roleService) + .assignRoleToActor(eq(SECOND_ACTOR_URN_STRING), eq(roleUrn), eq(_authentication)); + when(_roleService.exists(eq(Urn.createFromString(ROLE_URN_STRING)), eq(_authentication))).thenReturn(true); assertTrue(_resolver.get(_dataFetchingEnvironment).join()); - // Only the first actor should be assigned to the role since the second actor does not exist - verify(_entityClient, times(1)).ingestProposal(any(), eq(_authentication)); } @Test @@ -96,12 +94,8 @@ public void testAllActorsExist() throws Exception { actors.add(SECOND_ACTOR_URN_STRING); input.setActors(actors); when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); - when(_entityClient.exists(eq(Urn.createFromString(ROLE_URN_STRING)), eq(_authentication))).thenReturn(true); - when(_entityClient.exists(eq(Urn.createFromString(FIRST_ACTOR_URN_STRING)), eq(_authentication))).thenReturn(true); - when(_entityClient.exists(eq(Urn.createFromString(SECOND_ACTOR_URN_STRING)), eq(_authentication))).thenReturn(true); + when(_roleService.exists(eq(Urn.createFromString(ROLE_URN_STRING)), eq(_authentication))).thenReturn(true); assertTrue(_resolver.get(_dataFetchingEnvironment).join()); - // Both actors exist and should be assigned to the role - verify(_entityClient, times(2)).ingestProposal(any(), eq(_authentication)); } } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolverTest.java new file mode 100644 index 0000000000000..8d8faf5c3f12e --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolverTest.java @@ -0,0 +1,54 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CreateInviteTokenInput; +import graphql.schema.DataFetchingEnvironment; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class CreateInviteTokenResolverTest { + private static final String ROLE_URN_STRING = "urn:li:dataHubRole:Admin"; + private static final String INVITE_TOKEN_STRING = "inviteToken"; + private InviteTokenService _inviteTokenService; + private CreateInviteTokenResolver _resolver; + private DataFetchingEnvironment _dataFetchingEnvironment; + private Authentication _authentication; + + @BeforeMethod + public void setupTest() throws Exception { + _inviteTokenService = mock(InviteTokenService.class); + _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); + _authentication = mock(Authentication.class); + + _resolver = new CreateInviteTokenResolver(_inviteTokenService); + } + + @Test + public void testNotAuthorizedFails() { + QueryContext mockContext = getMockDenyContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + + assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); + } + + @Test + public void testPasses() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + when(mockContext.getAuthentication()).thenReturn(_authentication); + when(_inviteTokenService.getInviteToken(any(), eq(true), eq(_authentication))).thenReturn(INVITE_TOKEN_STRING); + + CreateInviteTokenInput input = new CreateInviteTokenInput(); + input.setRoleUrn(ROLE_URN_STRING); + when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); + + assertEquals(_resolver.get(_dataFetchingEnvironment).join().getInviteToken(), INVITE_TOKEN_STRING); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolverTest.java similarity index 50% rename from datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolverTest.java rename to datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolverTest.java index cf66367272fe5..ef426979953d0 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolverTest.java @@ -1,8 +1,9 @@ -package com.linkedin.datahub.graphql.resolvers.user; +package com.linkedin.datahub.graphql.resolvers.role; import com.datahub.authentication.Authentication; -import com.datahub.authentication.user.NativeUserService; +import com.datahub.authentication.invite.InviteTokenService; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.GetInviteTokenInput; import graphql.schema.DataFetchingEnvironment; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -12,26 +13,25 @@ import static org.testng.Assert.*; -public class CreateNativeUserInviteTokenResolverTest { - - private static final String INVITE_TOKEN = "inviteToken"; - - private NativeUserService _nativeUserService; - private CreateNativeUserInviteTokenResolver _resolver; +public class GetInviteTokenResolverTest { + private static final String ROLE_URN_STRING = "urn:li:dataHubRole:Admin"; + private static final String INVITE_TOKEN_STRING = "inviteToken"; + private InviteTokenService _inviteTokenService; + private GetInviteTokenResolver _resolver; private DataFetchingEnvironment _dataFetchingEnvironment; private Authentication _authentication; @BeforeMethod - public void setupTest() { - _nativeUserService = mock(NativeUserService.class); + public void setupTest() throws Exception { + _inviteTokenService = mock(InviteTokenService.class); _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); _authentication = mock(Authentication.class); - _resolver = new CreateNativeUserInviteTokenResolver(_nativeUserService); + _resolver = new GetInviteTokenResolver(_inviteTokenService); } @Test - public void testFailsCannotManageUserCredentials() { + public void testNotAuthorizedFails() { QueryContext mockContext = getMockDenyContext(); when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); @@ -43,8 +43,12 @@ public void testPasses() throws Exception { QueryContext mockContext = getMockAllowContext(); when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); when(mockContext.getAuthentication()).thenReturn(_authentication); - when(_nativeUserService.generateNativeUserInviteToken(any())).thenReturn(INVITE_TOKEN); + when(_inviteTokenService.getInviteToken(any(), eq(false), eq(_authentication))).thenReturn(INVITE_TOKEN_STRING); + + GetInviteTokenInput input = new GetInviteTokenInput(); + input.setRoleUrn(ROLE_URN_STRING); + when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); - assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken()); + assertEquals(_resolver.get(_dataFetchingEnvironment).join().getInviteToken(), INVITE_TOKEN_STRING); } } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolverTest.java deleted file mode 100644 index 0870bcae5661b..0000000000000 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolverTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.linkedin.datahub.graphql.resolvers.user; - -import com.datahub.authentication.Authentication; -import com.datahub.authentication.user.NativeUserService; -import com.linkedin.datahub.graphql.QueryContext; -import graphql.schema.DataFetchingEnvironment; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; - -import static com.linkedin.datahub.graphql.TestUtils.*; -import static org.mockito.Mockito.*; -import static org.testng.Assert.*; - - -public class GetNativeUserInviteTokenResolverTest { - - private static final String INVITE_TOKEN = "inviteToken"; - - private NativeUserService _nativeUserService; - private GetNativeUserInviteTokenResolver _resolver; - private DataFetchingEnvironment _dataFetchingEnvironment; - private Authentication _authentication; - - @BeforeMethod - public void setupTest() { - _nativeUserService = mock(NativeUserService.class); - _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); - _authentication = mock(Authentication.class); - - _resolver = new GetNativeUserInviteTokenResolver(_nativeUserService); - } - - @Test - public void testFailsCannotManageUserCredentials() { - QueryContext mockContext = getMockDenyContext(); - when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); - - assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); - } - - @Test - public void testPasses() throws Exception { - QueryContext mockContext = getMockAllowContext(); - when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); - when(mockContext.getAuthentication()).thenReturn(_authentication); - when(_nativeUserService.getNativeUserInviteToken(any())).thenReturn(INVITE_TOKEN); - - assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken()); - } -} diff --git a/datahub-web-react/src/app/auth/SignUp.tsx b/datahub-web-react/src/app/auth/SignUp.tsx index 12791855c0aab..17a271063235d 100644 --- a/datahub-web-react/src/app/auth/SignUp.tsx +++ b/datahub-web-react/src/app/auth/SignUp.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Input, Button, Form, message, Image, Select } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { useReactiveVar } from '@apollo/client'; import styled, { useTheme } from 'styled-components/macro'; -import { Redirect } from 'react-router'; +// import { Redirect } from 'react-router'; +import { useHistory } from 'react-router-dom'; import styles from './login.module.css'; import { Message } from '../shared/Message'; import { isLoggedInVar } from './checkAuthStatus'; @@ -11,6 +12,7 @@ import analytics, { EventType } from '../analytics'; import { useAppConfig } from '../useAppConfig'; import { PageRoutes } from '../../conf/Global'; import useGetInviteTokenFromUrlParams from './useGetInviteTokenFromUrlParams'; +import { useAcceptRoleMutation } from '../../graphql/mutations.generated'; type FormValues = { fullName: string; @@ -56,6 +58,7 @@ const TitleSelector = styled(Select)` export type SignUpProps = Record; export const SignUp: React.VFC = () => { + const history = useHistory(); const isLoggedIn = useReactiveVar(isLoggedInVar); const inviteToken = useGetInviteTokenFromUrlParams(); @@ -64,6 +67,32 @@ export const SignUp: React.VFC = () => { const { refreshContext } = useAppConfig(); + const [acceptRoleMutation] = useAcceptRoleMutation(); + const acceptRole = () => { + acceptRoleMutation({ + variables: { + input: { + inviteToken, + }, + }, + }) + .then(({ errors }) => { + if (!errors) { + message.success({ + content: `Accepted invite!`, + duration: 2, + }); + } + }) + .catch((e) => { + message.destroy(); + message.error({ + content: `Failed to accept invite: \n ${e.message || ''}`, + duration: 3, + }); + }); + }; + const handleSignUp = useCallback( (values: FormValues) => { setLoading(true); @@ -98,9 +127,12 @@ export const SignUp: React.VFC = () => { [refreshContext, inviteToken], ); - if (isLoggedIn && !loading) { - return ; - } + useEffect(() => { + if (isLoggedIn && !loading) { + acceptRole(); + history.push(PageRoutes.ROOT); + } + }); return (
@@ -112,10 +144,10 @@ export const SignUp: React.VFC = () => { {loading && }
Email} + label={} > } data-testid="email" /> diff --git a/datahub-web-react/src/app/identity/user/UserList.tsx b/datahub-web-react/src/app/identity/user/UserList.tsx index 4f29c69e9f810..8e88ca68a704e 100644 --- a/datahub-web-react/src/app/identity/user/UserList.tsx +++ b/datahub-web-react/src/app/identity/user/UserList.tsx @@ -45,7 +45,7 @@ export const UserList = () => { const [removedUrns, setRemovedUrns] = useState([]); const authenticatedUser = useGetAuthenticatedUser(); - const canManageUserCredentials = authenticatedUser?.platformPrivileges.manageUserCredentials || false; + const canManagePolicies = authenticatedUser?.platformPrivileges.managePolicies || false; const pageSize = DEFAULT_PAGE_SIZE; const start = (page - 1) * pageSize; @@ -110,11 +110,7 @@ export const UserList = () => {
-
@@ -146,7 +142,7 @@ export const UserList = () => { handleDelete(item.urn as string)} user={item as CorpUser} - canManageUserCredentials={canManageUserCredentials} + canManageUserCredentials={canManagePolicies} selectRoleOptions={selectRoleOptions} refetch={usersRefetch} /> @@ -163,7 +159,7 @@ export const UserList = () => { showSizeChanger={false} /> - {canManageUserCredentials && ( + {canManagePolicies && ( setIsViewingInviteToken(false)} diff --git a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx index d8d2d9ccc38ec..a731be26b4652 100644 --- a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx +++ b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx @@ -1,12 +1,15 @@ -import { RedoOutlined } from '@ant-design/icons'; -import { Button, Modal, Typography } from 'antd'; -import React from 'react'; -import styled from 'styled-components'; +import React, { useEffect, useState } from 'react'; +import * as QueryString from 'query-string'; +import { useLocation } from 'react-router'; +import { UserOutlined } from '@ant-design/icons'; +import { Button, message, Modal, Select, Tooltip, Typography } from 'antd'; +import styled from 'styled-components/macro'; import { PageRoutes } from '../../../conf/Global'; -import { - useCreateNativeUserInviteTokenMutation, - useGetNativeUserInviteTokenQuery, -} from '../../../graphql/user.generated'; +import { useGetInviteTokenQuery, useListRolesQuery } from '../../../graphql/role.generated'; +import { DataHubRole } from '../../../types.generated'; +import { mapRoleIcon } from './UserUtils'; +import { useCreateInviteTokenMutation } from '../../../graphql/mutations.generated'; +import { ANTD_GRAY } from '../../entity/shared/constants'; const ModalSection = styled.div` display: flex; @@ -14,7 +17,7 @@ const ModalSection = styled.div` padding-bottom: 12px; `; -const ModalSectionHeader = styled(Typography.Text)` +const ModalSectionFooter = styled(Typography.Paragraph)` &&&& { padding: 0px; margin: 0px; @@ -22,17 +25,42 @@ const ModalSectionHeader = styled(Typography.Text)` } `; -const ModalSectionParagraph = styled(Typography.Paragraph)` - &&&& { - padding: 0px; - margin: 0px; +const InviteLinkDiv = styled.div` + margin-top: -12px; + display: flex; + flex-direction: row; + justify-content: flex-start; + gap: 10px; + align-items: center; +`; + +const CopyText = styled(Typography.Text)` + display: flex; + gap: 10px; + align-items: center; +`; + +const CopyButton = styled(Button)` + background-color: #1890ff; + color: white; + + &:focus:not(:hover) { + background-color: #1890ff; + color: white; } `; -const CreateInviteTokenButton = styled(Button)` - display: inline-block; - width: 20px; - margin-left: -6px; +const RefreshButton = styled(Button)` + color: ${ANTD_GRAY[7]}; +`; + +const RoleSelect = styled(Select)` + min-width: 105px; +`; + +const RoleIcon = styled.span` + margin-right: 6px; + font-size: 12px; `; type Props = { @@ -42,46 +70,146 @@ type Props = { export default function ViewInviteTokenModal({ visible, onClose }: Props) { const baseUrl = window.location.origin; - const { data: getNativeUserInviteTokenData } = useGetNativeUserInviteTokenQuery({ skip: !visible }); + const location = useLocation(); + const params = QueryString.parse(location.search, { arrayFormat: 'comma' }); + const paramsQuery = (params?.query as string) || undefined; + const [query, setQuery] = useState(undefined); + useEffect(() => setQuery(paramsQuery), [paramsQuery]); + const [selectedRole, setSelectedRole] = useState(); + + // Code related to listing role options and selecting a role + const noRoleText = 'No Role'; + + const { data: rolesData } = useListRolesQuery({ + fetchPolicy: 'no-cache', + variables: { + input: { + start: 0, + count: 10, + query, + }, + }, + }); + const selectRoleOptions = rolesData?.listRoles?.roles?.map((role) => role as DataHubRole) || []; + + const rolesMap: Map = new Map(); + selectRoleOptions.forEach((role) => { + rolesMap.set(role.urn, role); + }); + + const roleSelectOptions = () => + selectRoleOptions.map((role) => { + return ( + + {mapRoleIcon(role.name)} + {role.name} + + ); + }); - const [createNativeUserInviteToken, { data: createNativeUserInviteTokenData }] = - useCreateNativeUserInviteTokenMutation({}); + // Code related to getting or creating an invite token + const { data: getInviteTokenData } = useGetInviteTokenQuery({ + skip: !visible, + variables: { input: { roleUrn: selectedRole?.urn } }, + }); - const inviteToken = createNativeUserInviteTokenData?.createNativeUserInviteToken?.inviteToken - ? createNativeUserInviteTokenData?.createNativeUserInviteToken.inviteToken - : getNativeUserInviteTokenData?.getNativeUserInviteToken?.inviteToken || ''; + const [inviteToken, setInviteToken] = useState(getInviteTokenData?.getInviteToken?.inviteToken || ''); + + const [createInviteTokenMutation] = useCreateInviteTokenMutation(); + + useEffect(() => { + if (getInviteTokenData?.getInviteToken?.inviteToken) { + setInviteToken(getInviteTokenData.getInviteToken.inviteToken); + } + }, [getInviteTokenData]); + + const onSelectRole = (roleUrn: string) => { + const roleFromMap: DataHubRole = rolesMap.get(roleUrn) as DataHubRole; + setSelectedRole(roleFromMap); + }; + + const createInviteToken = (roleUrn?: string) => { + createInviteTokenMutation({ + variables: { + input: { + roleUrn, + }, + }, + }) + .then(({ data, errors }) => { + if (!errors) { + setInviteToken(data?.createInviteToken?.inviteToken || ''); + message.success('Generated new invite link'); + } + }) + .catch((e) => { + message.destroy(); + message.error({ + content: `Failed to create Invite Token for role ${selectedRole?.name} : \n ${e.message || ''}`, + duration: 3, + }); + }); + }; const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}`; return ( - Invite new DataHub users + Share Invite Link } visible={visible} onCancel={onClose} > - Share invite link - - Share this invite link with other users in your workspace! - - -
{inviteLink}
-
-
- - Generate a new link - - Generate a new invite link! Note, any old links will cease to be active. - - createNativeUserInviteToken({})} size="small" type="text"> - - + + + + {noRoleText} + + } + value={selectedRole?.urn || undefined} + onChange={(e) => onSelectRole(e as string)} + > + + {mapRoleIcon(noRoleText)} + {noRoleText} + + {roleSelectOptions()} + + +
{inviteLink}
+
+ + { + navigator.clipboard.writeText(inviteLink); + message.success('Copied invite link to clipboard'); + }} + > + COPY + + + + { + createInviteToken(selectedRole?.urn); + }} + > + REFRESH + + +
+ + Copy an invite link to send to your users. When they join, users will be automatically assigned to + the selected role. +
); diff --git a/datahub-web-react/src/app/permissions/ManagePermissions.tsx b/datahub-web-react/src/app/permissions/ManagePermissions.tsx index cf0efd2a1a2cb..7bf517e153fa8 100644 --- a/datahub-web-react/src/app/permissions/ManagePermissions.tsx +++ b/datahub-web-react/src/app/permissions/ManagePermissions.tsx @@ -42,7 +42,7 @@ const ENABLED_TAB_TYPES = [TabType.Roles, TabType.Policies]; export const ManagePermissions = () => { /** - * Determines which view should be visible: users or groups list. + * Determines which view should be visible: roles or policies. */ const getTabs = () => { diff --git a/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx b/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx index a7ae84ade135e..e5220296f9ece 100644 --- a/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx +++ b/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx @@ -32,6 +32,10 @@ const PageContainer = styled.span` width: 100%; `; +const AddUsersButton = styled(Button)` + margin-right: 16px; +`; + const DEFAULT_PAGE_SIZE = 10; // TODO: Cleanup the styling. @@ -160,20 +164,22 @@ export const ManageRoles = () => { }, }, { - dataIndex: 'add_users', - key: 'add_users', + dataIndex: 'actions', + key: 'actions', render: (_: any, record: any) => { return ( - - - + <> + + { + setIsBatchAddRolesModalVisible(true); + setFocusRole(record.role); + }} + > + ADD USERS + + + ); }, }, @@ -247,13 +253,7 @@ export const ManageRoles = () => { showSizeChanger={false} /> - {showViewRoleModal && ( - - )} + ); }; diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index d809b12e13e6f..105289fe9bb05 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -104,4 +104,14 @@ mutation batchUpdateSoftDeleted($input: BatchUpdateSoftDeletedInput!) { mutation batchAssignRole($input: BatchAssignRoleInput!) { batchAssignRole(input: $input) +} + +mutation createInviteToken($input: CreateInviteTokenInput!) { + createInviteToken(input: $input) { + inviteToken + } +} + +mutation acceptRole($input: AcceptRoleInput!) { + acceptRole(input: $input) } \ No newline at end of file diff --git a/datahub-web-react/src/graphql/role.graphql b/datahub-web-react/src/graphql/role.graphql index b98dec3f54001..05936c7cf6810 100644 --- a/datahub-web-react/src/graphql/role.graphql +++ b/datahub-web-react/src/graphql/role.graphql @@ -59,3 +59,9 @@ query listRoles($input: ListRolesInput!) { } } } + +query getInviteToken($input: GetInviteTokenInput!) { + getInviteToken(input: $input) { + inviteToken + } +} diff --git a/datahub-web-react/src/graphql/user.graphql b/datahub-web-react/src/graphql/user.graphql index 62b299612bab0..8e3d33e2bc34e 100644 --- a/datahub-web-react/src/graphql/user.graphql +++ b/datahub-web-react/src/graphql/user.graphql @@ -181,18 +181,6 @@ mutation updateCorpUserProperties($urn: String!, $input: CorpUserUpdateInput!) { } } -mutation createNativeUserInviteToken { - createNativeUserInviteToken { - inviteToken - } -} - -query getNativeUserInviteToken { - getNativeUserInviteToken { - inviteToken - } -} - mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) { createNativeUserResetToken(input: $input) { resetToken diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index ccf90ec09857d..3e81867c40842 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -12,6 +12,24 @@ This file documents any backwards-incompatible changes in DataHub and assists pe ### Other notable Changes +## `v0.8.45` + +### Breaking Changes +- The `getNativeUserInviteToken` and `createNativeUserInviteToken` GraphQL endpoints have been renamed to + `getInviteToken` and `createInviteToken` respectively. Additionally, both now accept an optional `roleUrn` parameter. + Both endpoints also now require the `MANAGE_POLICIES` privilege to execute, rather than `MANAGE_USER_CREDENTIALS` + privilege. +- One of the default policies shipped with DataHub (`urn:li:dataHubPolicy:7`, or `All Users - All Platform Privileges`) + has been edited to no longer include `MANAGE_POLICIES`. Its name has consequently been changed to + `All Users - All Platform Privileges (EXCEPT MANAGE POLICIES)`. This change was made to prevent all users from + effectively acting as superusers by default. + +### Potential Downtime + +### Deprecations + +### Other notable Changes + ## `v0.8.44` ### Breaking Changes diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index d741a5dcfbbb4..adee116295d44 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -15,8 +15,6 @@ public class Constants { public static final String DEFAULT_RUN_ID = "no-run-id-provided"; - public static final String GLOBAL_INVITE_TOKEN = "urn:li:inviteToken:global"; - /** * Entities */ @@ -236,6 +234,10 @@ public class Constants { // Invite Token public static final String INVITE_TOKEN_ASPECT_NAME = "inviteToken"; + public static final int INVITE_TOKEN_LENGTH = 32; + public static final int SALT_TOKEN_LENGTH = 16; + public static final int PASSWORD_RESET_TOKEN_LENGTH = 32; + // Relationships public static final String IS_MEMBER_OF_GROUP_RELATIONSHIP_NAME = "IsMemberOfGroup"; diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectUtils.java index 2eb655c005928..a046ff71713c8 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectUtils.java @@ -1,10 +1,12 @@ package com.linkedin.metadata.entity; +import com.datahub.authentication.Authentication; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; import com.linkedin.entity.Aspect; import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; @@ -17,11 +19,10 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; -import com.linkedin.entity.client.EntityClient; -import com.datahub.authentication.Authentication; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; + @Slf4j public class AspectUtils { @@ -85,9 +86,7 @@ private static MetadataChangeProposal getProposalFromAspect(String aspectName, R } public static MetadataChangeProposal buildMetadataChangeProposal( - @Nonnull Urn urn, - @Nonnull String aspectName, - @Nonnull RecordTemplate aspect) { + @Nonnull Urn urn, @Nonnull String aspectName, @Nonnull RecordTemplate aspect) { final MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(urn); proposal.setEntityType(urn.getEntityType()); @@ -96,4 +95,15 @@ public static MetadataChangeProposal buildMetadataChangeProposal( proposal.setChangeType(ChangeType.UPSERT); return proposal; } + + public static MetadataChangeProposal buildMetadataChangeProposal(@Nonnull String entityType, + @Nonnull RecordTemplate keyAspect, @Nonnull String aspectName, @Nonnull RecordTemplate aspect) { + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityType(entityType); + proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(keyAspect)); + proposal.setAspectName(aspectName); + proposal.setAspect(GenericRecordUtils.serializeAspect(aspect)); + proposal.setChangeType(ChangeType.UPSERT); + return proposal; + } } \ No newline at end of file diff --git a/metadata-io/src/main/java/com/linkedin/metadata/secret/SecretService.java b/metadata-io/src/main/java/com/linkedin/metadata/secret/SecretService.java index 70069b6c22e58..1995e3c1b80a1 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/secret/SecretService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/secret/SecretService.java @@ -1,20 +1,40 @@ package com.linkedin.metadata.secret; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; public class SecretService { + private static final int LOWERCASE_ASCII_START = 97; + private static final int LOWERCASE_ASCII_END = 122; + public static final String HASHING_ALGORITHM = "SHA-256"; private final String _secret; + private final SecureRandom _secureRandom; + private final Base64.Encoder _encoder; + private final Base64.Decoder _decoder; + private final MessageDigest _messageDigest; public SecretService(final String secret) { _secret = secret; + _secureRandom = new SecureRandom(); + _encoder = Base64.getEncoder(); + _decoder = Base64.getDecoder(); + try { + _messageDigest = MessageDigest.getInstance(HASHING_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Unable to create MessageDigest", e); + } } public String encrypt(String value) { @@ -33,7 +53,7 @@ public String encrypt(String value) { } Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, secretKey); - return Base64.getEncoder().encodeToString(cipher.doFinal(value.getBytes(StandardCharsets.UTF_8))); + return _encoder.encodeToString(cipher.doFinal(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new RuntimeException("Failed to encrypt value using provided secret!", e); } @@ -55,9 +75,40 @@ public String decrypt(String encryptedValue) { } Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5PADDING"); cipher.init(Cipher.DECRYPT_MODE, secretKey); - return new String(cipher.doFinal(Base64.getDecoder().decode(encryptedValue))); + return new String(cipher.doFinal(_decoder.decode(encryptedValue))); } catch (Exception e) { throw new RuntimeException("Failed to decrypt value using provided secret!", e); } } + + public String generateUrlSafeToken(int length) { + return _secureRandom.ints(length, LOWERCASE_ASCII_START, LOWERCASE_ASCII_END + 1) + .mapToObj(i -> String.valueOf((char) i)) + .collect(Collectors.joining()); + } + + public String hashString(@Nonnull final String str) { + byte[] hashedBytes = _messageDigest.digest(str.getBytes()); + return _encoder.encodeToString(hashedBytes); + } + + public byte[] generateSalt(int length) { + byte[] randomBytes = new byte[length]; + _secureRandom.nextBytes(randomBytes); + return randomBytes; + } + + public String getHashedPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException { + byte[] saltedPassword = saltPassword(salt, password); + byte[] hashedPassword = _messageDigest.digest(saltedPassword); + return _encoder.encodeToString(hashedPassword); + } + + byte[] saltPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException { + byte[] passwordBytes = password.getBytes(); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + byteArrayOutputStream.write(salt); + byteArrayOutputStream.write(passwordBytes); + return byteArrayOutputStream.toByteArray(); + } } diff --git a/metadata-models/src/main/pegasus/com/linkedin/identity/InviteToken.pdl b/metadata-models/src/main/pegasus/com/linkedin/identity/InviteToken.pdl index 16559bfc2ccaa..4b8c803944ef2 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/identity/InviteToken.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/identity/InviteToken.pdl @@ -3,7 +3,7 @@ namespace com.linkedin.identity import com.linkedin.common.Urn /** - * Aspect used to store the token needed to invite native DataHub users + * Aspect used to store invite tokens. */ @Aspect = { "name": "inviteToken" @@ -13,4 +13,14 @@ record InviteToken { * The encrypted invite token. */ token: string + + /** + * The role that this invite token may be associated with + */ + @Searchable = { + "fieldName": "role", + "fieldType": "KEYWORD", + "hasValuesFieldName": "hasRole" + } + role: optional Urn } diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/invite/InviteTokenService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/invite/InviteTokenService.java new file mode 100644 index 0000000000000..266361b4f455e --- /dev/null +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/invite/InviteTokenService.java @@ -0,0 +1,175 @@ +package com.datahub.authentication.invite; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.key.InviteTokenKey; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.CriterionArray; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.secret.SecretService; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; +import java.net.URISyntaxException; +import java.util.Collections; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.entity.AspectUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class InviteTokenService { + private static final String ROLE_FIELD_NAME = "role"; + private static final String HAS_ROLE_FIELD_NAME = "hasRole"; + private final EntityClient _entityClient; + private final SecretService _secretService; + + public Urn getInviteTokenUrn(@Nonnull final String inviteTokenStr) throws URISyntaxException { + String hashedInviteTokenStr = _secretService.hashString(inviteTokenStr); + String inviteTokenUrnStr = String.format("urn:li:inviteToken:%s", hashedInviteTokenStr); + return Urn.createFromString(inviteTokenUrnStr); + } + + public boolean isInviteTokenValid(@Nonnull final Urn inviteTokenUrn, @Nonnull final Authentication authentication) + throws RemoteInvocationException { + return _entityClient.exists(inviteTokenUrn, authentication); + } + + @Nullable + public Urn getInviteTokenRole(@Nonnull final Urn inviteTokenUrn, @Nonnull final Authentication authentication) + throws URISyntaxException, RemoteInvocationException { + com.linkedin.identity.InviteToken inviteToken = getInviteTokenEntity(inviteTokenUrn, authentication); + return inviteToken.hasRole() ? inviteToken.getRole() : null; + } + + @Nonnull + public String getInviteToken(@Nullable final String roleUrnStr, boolean regenerate, + @Nonnull final Authentication authentication) throws Exception { + final Filter inviteTokenFilter = + roleUrnStr == null ? createInviteTokenFilter() : createInviteTokenFilter(roleUrnStr); + + final SearchResult searchResult = + _entityClient.filter(INVITE_TOKEN_ENTITY_NAME, inviteTokenFilter, null, 0, 10, authentication); + + final int numEntities = searchResult.getEntities().size(); + // If there is more than one invite token, wipe all of them and generate a fresh one + if (numEntities > 1) { + deleteExistingInviteTokens(searchResult, authentication); + return createInviteToken(roleUrnStr, authentication); + } + + // If we want to regenerate, or there are no entities in the result, create a new invite token. + if (regenerate || numEntities == 0) { + return createInviteToken(roleUrnStr, authentication); + } + + final SearchEntity searchEntity = searchResult.getEntities().get(0); + final Urn inviteTokenUrn = searchEntity.getEntity(); + + com.linkedin.identity.InviteToken inviteToken = getInviteTokenEntity(inviteTokenUrn, authentication); + return _secretService.decrypt(inviteToken.getToken()); + } + + private com.linkedin.identity.InviteToken getInviteTokenEntity(@Nonnull final Urn inviteTokenUrn, + @Nonnull final Authentication authentication) throws RemoteInvocationException, URISyntaxException { + final EntityResponse inviteTokenEntity = + _entityClient.getV2(INVITE_TOKEN_ENTITY_NAME, inviteTokenUrn, Collections.singleton(INVITE_TOKEN_ASPECT_NAME), + authentication); + + if (inviteTokenEntity == null) { + throw new RuntimeException(String.format("Invite token %s does not exist", inviteTokenUrn)); + } + + final EnvelopedAspectMap aspectMap = inviteTokenEntity.getAspects(); + // If invite token aspect is not present, create a new one. Otherwise, return existing one. + if (!aspectMap.containsKey(INVITE_TOKEN_ASPECT_NAME)) { + throw new RuntimeException( + String.format("Invite token %s does not contain aspect %s", inviteTokenUrn, INVITE_TOKEN_ASPECT_NAME)); + } + return new com.linkedin.identity.InviteToken(aspectMap.get(INVITE_TOKEN_ASPECT_NAME).getValue().data()); + } + + private Filter createInviteTokenFilter() { + final Filter filter = new Filter(); + final ConjunctiveCriterionArray disjunction = new ConjunctiveCriterionArray(); + final ConjunctiveCriterion conjunction = new ConjunctiveCriterion(); + final CriterionArray andCriterion = new CriterionArray(); + + final Criterion roleCriterion = new Criterion(); + roleCriterion.setField(HAS_ROLE_FIELD_NAME); + roleCriterion.setValue("false"); + roleCriterion.setCondition(Condition.EQUAL); + + andCriterion.add(roleCriterion); + conjunction.setAnd(andCriterion); + disjunction.add(conjunction); + filter.setOr(disjunction); + + return filter; + } + + private Filter createInviteTokenFilter(@Nonnull final String roleUrnStr) { + final Filter filter = new Filter(); + final ConjunctiveCriterionArray disjunction = new ConjunctiveCriterionArray(); + final ConjunctiveCriterion conjunction = new ConjunctiveCriterion(); + final CriterionArray andCriterion = new CriterionArray(); + + final Criterion roleCriterion = new Criterion(); + roleCriterion.setField(ROLE_FIELD_NAME); + roleCriterion.setValue(roleUrnStr); + roleCriterion.setCondition(Condition.EQUAL); + + andCriterion.add(roleCriterion); + conjunction.setAnd(andCriterion); + disjunction.add(conjunction); + filter.setOr(disjunction); + + return filter; + } + + @Nonnull + private String createInviteToken(@Nullable final String roleUrnStr, @Nonnull final Authentication authentication) + throws Exception { + String inviteTokenStr = _secretService.generateUrlSafeToken(INVITE_TOKEN_LENGTH); + String hashedInviteTokenStr = _secretService.hashString(inviteTokenStr); + InviteTokenKey inviteTokenKey = new InviteTokenKey(); + inviteTokenKey.setId(hashedInviteTokenStr); + com.linkedin.identity.InviteToken inviteTokenAspect = + new com.linkedin.identity.InviteToken().setToken(_secretService.encrypt(inviteTokenStr)); + if (roleUrnStr != null) { + Urn roleUrn = Urn.createFromString(roleUrnStr); + inviteTokenAspect.setRole(roleUrn); + } + + // Ingest new InviteToken aspect + final MetadataChangeProposal proposal = + buildMetadataChangeProposal(INVITE_TOKEN_ENTITY_NAME, inviteTokenKey, INVITE_TOKEN_ASPECT_NAME, + inviteTokenAspect); + _entityClient.ingestProposal(proposal, authentication); + + return inviteTokenStr; + } + + private void deleteExistingInviteTokens(@Nonnull final SearchResult searchResult, + @Nonnull final Authentication authentication) { + searchResult.getEntities().forEach(entity -> { + try { + _entityClient.deleteEntity(entity.getEntity(), authentication); + } catch (RemoteInvocationException e) { + log.error(String.format("Failed to delete invite token entity %s", entity.getEntity()), e); + } + }); + } +} diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java index e83b89e7d8c6f..73b1fc90cdffb 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java @@ -8,21 +8,16 @@ import com.linkedin.identity.CorpUserCredentials; import com.linkedin.identity.CorpUserInfo; import com.linkedin.identity.CorpUserStatus; -import com.linkedin.identity.InviteToken; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.secret.SecretService; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.security.MessageDigest; -import java.security.SecureRandom; import java.time.Instant; import java.util.Base64; import java.util.Objects; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import static com.linkedin.metadata.Constants.*; @@ -32,52 +27,22 @@ * Service responsible for creating, updating and authenticating native DataHub users. */ @Slf4j +@RequiredArgsConstructor public class NativeUserService { - private static final int LOWERCASE_ASCII_START = 97; - private static final int LOWERCASE_ASCII_END = 122; - private static final int INVITE_TOKEN_LENGTH = 32; - private static final int SALT_TOKEN_LENGTH = 16; - private static final int PASSWORD_RESET_TOKEN_LENGTH = 32; - private static final String HASHING_ALGORITHM = "SHA-256"; private static final long ONE_DAY_MILLIS = TimeUnit.DAYS.toMillis(1); private final EntityService _entityService; private final EntityClient _entityClient; private final SecretService _secretService; - private final SecureRandom _secureRandom; - private final MessageDigest _messageDigest; - - public NativeUserService(@Nonnull EntityService entityService, @Nonnull EntityClient entityClient, @Nonnull SecretService secretService) - throws Exception { - Objects.requireNonNull(entityService, "entityService must not be null!"); - Objects.requireNonNull(entityClient, "entityClient must not be null!"); - Objects.requireNonNull(secretService, "secretService must not be null!"); - - _entityService = entityService; - _entityClient = entityClient; - _secretService = secretService; - _secureRandom = new SecureRandom(); - _messageDigest = MessageDigest.getInstance(HASHING_ALGORITHM); - } public void createNativeUser(@Nonnull String userUrnString, @Nonnull String fullName, @Nonnull String email, - @Nonnull String title, @Nonnull String password, @Nonnull String inviteToken, @Nonnull Authentication authentication) - throws Exception { + @Nonnull String title, @Nonnull String password, @Nonnull Authentication authentication) throws Exception { Objects.requireNonNull(userUrnString, "userUrnSting must not be null!"); Objects.requireNonNull(fullName, "fullName must not be null!"); Objects.requireNonNull(email, "email must not be null!"); Objects.requireNonNull(title, "title must not be null!"); Objects.requireNonNull(password, "password must not be null!"); - Objects.requireNonNull(inviteToken, "inviteToken must not be null!"); - Objects.requireNonNull(inviteToken, "authentication must not be null!"); - - InviteToken inviteTokenAspect = - (InviteToken) _entityService.getLatestAspect(Urn.createFromString(GLOBAL_INVITE_TOKEN), - INVITE_TOKEN_ASPECT_NAME); - if (inviteTokenAspect == null || !inviteTokenAspect.hasToken() || !_secretService.decrypt( - inviteTokenAspect.getToken()).equals(inviteToken)) { - throw new RuntimeException("Invalid sign-up token. Please ask your administrator to send you an updated link!"); - } + Objects.requireNonNull(authentication, "authentication must not be null!"); Urn userUrn = Urn.createFromString(userUrnString); if (_entityService.exists(userUrn)) { @@ -125,14 +90,14 @@ void updateCorpUserStatus(@Nonnull Urn userUrn, Authentication authentication) t _entityClient.ingestProposal(corpUserStatusProposal, authentication); } - void updateCorpUserCredentials(@Nonnull Urn userUrn, @Nonnull String password, - @Nonnull Authentication authentication) throws Exception { + void updateCorpUserCredentials(@Nonnull Urn userUrn, @Nonnull String password, @Nonnull Authentication authentication) + throws Exception { // Construct corpUserCredentials CorpUserCredentials corpUserCredentials = new CorpUserCredentials(); - final byte[] salt = getRandomBytes(SALT_TOKEN_LENGTH); + final byte[] salt = _secretService.generateSalt(SALT_TOKEN_LENGTH); String encryptedSalt = _secretService.encrypt(Base64.getEncoder().encodeToString(salt)); corpUserCredentials.setSalt(encryptedSalt); - String hashedPassword = getHashedPassword(salt, password); + String hashedPassword = _secretService.getHashedPassword(salt, password); corpUserCredentials.setHashedPassword(hashedPassword); // Ingest corpUserCredentials MCP @@ -145,35 +110,8 @@ void updateCorpUserCredentials(@Nonnull Urn userUrn, @Nonnull String password, _entityClient.ingestProposal(corpUserCredentialsProposal, authentication); } - public String generateNativeUserInviteToken(Authentication authentication) throws Exception { - // Construct inviteToken - InviteToken inviteToken = new InviteToken(); - String token = generateRandomLowercaseToken(INVITE_TOKEN_LENGTH); - inviteToken.setToken(_secretService.encrypt(token)); - - // Ingest corpUserCredentials MCP - final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal(); - inviteTokenProposal.setEntityType(INVITE_TOKEN_ENTITY_NAME); - inviteTokenProposal.setEntityUrn(Urn.createFromString(GLOBAL_INVITE_TOKEN)); - inviteTokenProposal.setAspectName(INVITE_TOKEN_ASPECT_NAME); - inviteTokenProposal.setAspect(GenericRecordUtils.serializeAspect(inviteToken)); - inviteTokenProposal.setChangeType(ChangeType.UPSERT); - _entityClient.ingestProposal(inviteTokenProposal, authentication); - - return token; - } - - public String getNativeUserInviteToken(Authentication authentication) throws Exception { - InviteToken inviteToken = (InviteToken) _entityService.getLatestAspect(Urn.createFromString(GLOBAL_INVITE_TOKEN), - INVITE_TOKEN_ASPECT_NAME); - if (inviteToken == null || !inviteToken.hasToken()) { - return generateNativeUserInviteToken(authentication); - } - return _secretService.decrypt(inviteToken.getToken()); - } - - public String generateNativeUserPasswordResetToken(@Nonnull String userUrnString, - Authentication authentication) throws Exception { + public String generateNativeUserPasswordResetToken(@Nonnull String userUrnString, Authentication authentication) + throws Exception { Objects.requireNonNull(userUrnString, "userUrnString must not be null!"); Urn userUrn = Urn.createFromString(userUrnString); @@ -184,7 +122,7 @@ public String generateNativeUserPasswordResetToken(@Nonnull String userUrnString throw new RuntimeException("User does not exist or is a non-native user!"); } // Add reset token to CorpUserCredentials - String passwordResetToken = generateRandomLowercaseToken(PASSWORD_RESET_TOKEN_LENGTH); + String passwordResetToken = _secretService.generateUrlSafeToken(PASSWORD_RESET_TOKEN_LENGTH); corpUserCredentials.setPasswordResetToken(_secretService.encrypt(passwordResetToken)); long expirationTime = Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli(); @@ -217,14 +155,12 @@ public void resetCorpUserCredentials(@Nonnull String userUrnString, @Nonnull Str throw new RuntimeException("User does not exist!"); } - if (!corpUserCredentials.hasPasswordResetToken() - || !corpUserCredentials.hasPasswordResetTokenExpirationTimeMillis() + if (!corpUserCredentials.hasPasswordResetToken() || !corpUserCredentials.hasPasswordResetTokenExpirationTimeMillis() || corpUserCredentials.getPasswordResetTokenExpirationTimeMillis() == null) { throw new RuntimeException("User has not generated a password reset token!"); } - if (!_secretService.decrypt( - corpUserCredentials.getPasswordResetToken()).equals(resetToken)) { + if (!_secretService.decrypt(corpUserCredentials.getPasswordResetToken()).equals(resetToken)) { throw new RuntimeException("Invalid reset token. Please ask your administrator to send you an updated link!"); } @@ -234,10 +170,10 @@ public void resetCorpUserCredentials(@Nonnull String userUrnString, @Nonnull Str } // Construct corpUserCredentials - final byte[] salt = getRandomBytes(SALT_TOKEN_LENGTH); + final byte[] salt = _secretService.generateSalt(SALT_TOKEN_LENGTH); String encryptedSalt = _secretService.encrypt(Base64.getEncoder().encodeToString(salt)); corpUserCredentials.setSalt(encryptedSalt); - String hashedPassword = getHashedPassword(salt, password); + String hashedPassword = _secretService.getHashedPassword(salt, password); corpUserCredentials.setHashedPassword(hashedPassword); // Ingest corpUserCredentials MCP @@ -250,32 +186,6 @@ public void resetCorpUserCredentials(@Nonnull String userUrnString, @Nonnull Str _entityClient.ingestProposal(corpUserCredentialsProposal, authentication); } - byte[] getRandomBytes(int length) { - byte[] randomBytes = new byte[length]; - _secureRandom.nextBytes(randomBytes); - return randomBytes; - } - - String generateRandomLowercaseToken(int length) { - return _secureRandom.ints(length, LOWERCASE_ASCII_START, LOWERCASE_ASCII_END + 1) - .mapToObj(i -> String.valueOf((char) i)) - .collect(Collectors.joining()); - } - - byte[] saltPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException { - byte[] passwordBytes = password.getBytes(); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - byteArrayOutputStream.write(salt); - byteArrayOutputStream.write(passwordBytes); - return byteArrayOutputStream.toByteArray(); - } - - public String getHashedPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException { - byte[] saltedPassword = saltPassword(salt, password); - byte[] hashedPassword = _messageDigest.digest(saltedPassword); - return Base64.getEncoder().encodeToString(hashedPassword); - } - public boolean doesPasswordMatch(@Nonnull String userUrnString, @Nonnull String password) throws Exception { Objects.requireNonNull(userUrnString, "userUrnSting must not be null!"); Objects.requireNonNull(password, "Password must not be null!"); @@ -290,7 +200,7 @@ public boolean doesPasswordMatch(@Nonnull String userUrnString, @Nonnull String String decryptedSalt = _secretService.decrypt(corpUserCredentials.getSalt()); byte[] salt = Base64.getDecoder().decode(decryptedSalt); String storedHashedPassword = corpUserCredentials.getHashedPassword(); - String hashedPassword = getHashedPassword(salt, password); + String hashedPassword = _secretService.getHashedPassword(salt, password); return storedHashedPassword.equals(hashedPassword); } } diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/role/RoleService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/role/RoleService.java new file mode 100644 index 0000000000000..4a419e93b9856 --- /dev/null +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/role/RoleService.java @@ -0,0 +1,45 @@ +package com.datahub.authorization.role; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.identity.RoleMembership; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; +import java.net.URISyntaxException; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.entity.AspectUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class RoleService { + private final EntityClient _entityClient; + + public boolean exists(@Nonnull final Urn urn, final Authentication authentication) throws RemoteInvocationException { + return _entityClient.exists(urn, authentication); + } + + public void assignRoleToActor(@Nonnull final String actor, @Nonnull final Urn roleUrn, + @Nonnull final Authentication authentication) throws URISyntaxException, RemoteInvocationException { + Urn actorUrn = Urn.createFromString(actor); + if (!_entityClient.exists(actorUrn, authentication)) { + log.warn(String.format("Failed to assign role %s to actor %s, actor does not exist. Skipping actor assignment", + roleUrn, actor)); + return; + } + + RoleMembership roleMembership = new RoleMembership(); + roleMembership.setRoles(new UrnArray(roleUrn)); + + // Ingest new RoleMembership aspect + final MetadataChangeProposal proposal = + buildMetadataChangeProposal(actorUrn, ROLE_MEMBERSHIP_ASPECT_NAME, roleMembership); + _entityClient.ingestProposal(proposal, authentication); + } +} diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java new file mode 100644 index 0000000000000..2eed108b40300 --- /dev/null +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java @@ -0,0 +1,209 @@ +package com.datahub.authentication.invite; + +import com.datahub.authentication.Actor; +import com.datahub.authentication.ActorType; +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +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.identity.InviteToken; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.secret.SecretService; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static com.linkedin.metadata.Constants.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class InviteTokenServiceTest { + private static final String INVITE_TOKEN_URN_STRING = "urn:li:inviteToken:admin-invite-token"; + private static final String ROLE_URN_STRING = "urn:li:dataHubRole:Admin"; + private static final String INVITE_TOKEN_STRING = "inviteToken"; + private static final String HASHED_INVITE_TOKEN_STRING = "hashedInviteToken"; + private static final String ENCRYPTED_INVITE_TOKEN_STRING = "encryptedInviteToken"; + private static final String DATAHUB_SYSTEM_CLIENT_ID = "__datahub_system"; + private static final Authentication SYSTEM_AUTHENTICATION = + new Authentication(new Actor(ActorType.USER, DATAHUB_SYSTEM_CLIENT_ID), ""); + private Urn inviteTokenUrn; + private Urn roleUrn; + private EntityClient _entityClient; + private SecretService _secretService; + private InviteTokenService _inviteTokenService; + + @BeforeMethod + public void setupTest() throws Exception { + inviteTokenUrn = Urn.createFromString(INVITE_TOKEN_URN_STRING); + roleUrn = Urn.createFromString(ROLE_URN_STRING); + _entityClient = mock(EntityClient.class); + _secretService = mock(SecretService.class); + + _inviteTokenService = new InviteTokenService(_entityClient, _secretService); + } + + @Test + public void testGetInviteTokenUrnPasses() throws Exception { + _inviteTokenService.getInviteTokenUrn(INVITE_TOKEN_STRING); + } + + @Test + public void testIsInviteTokenValidFalse() throws Exception { + when(_entityClient.exists(eq(inviteTokenUrn), eq(SYSTEM_AUTHENTICATION))).thenReturn(false); + + assertFalse(_inviteTokenService.isInviteTokenValid(inviteTokenUrn, SYSTEM_AUTHENTICATION)); + } + + @Test + public void testIsInviteTokenValidTrue() throws Exception { + when(_entityClient.exists(eq(inviteTokenUrn), eq(SYSTEM_AUTHENTICATION))).thenReturn(true); + + assertTrue(_inviteTokenService.isInviteTokenValid(inviteTokenUrn, SYSTEM_AUTHENTICATION)); + } + + @Test + public void testGetInviteTokenRoleNullEntity() throws Exception { + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(null); + + assertThrows(() -> _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION)); + } + + @Test + public void testGetInviteTokenRoleEmptyAspectMap() throws Exception { + final EntityResponse entityResponse = new EntityResponse().setAspects(new EnvelopedAspectMap()); + + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); + + assertThrows(() -> _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION)); + } + + @Test + public void testGetInviteTokenRoleNoRole() throws Exception { + final EntityResponse entityResponse = new EntityResponse(); + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + final InviteToken inviteTokenAspect = new InviteToken().setToken(ENCRYPTED_INVITE_TOKEN_STRING); + aspectMap.put(INVITE_TOKEN_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(inviteTokenAspect.data()))); + entityResponse.setAspects(aspectMap); + + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); + + Urn roleUrn = _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertNull(roleUrn); + } + + @Test + public void testGetInviteTokenRole() throws Exception { + final EntityResponse entityResponse = new EntityResponse(); + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + final InviteToken inviteTokenAspect = new InviteToken().setToken(ENCRYPTED_INVITE_TOKEN_STRING).setRole(roleUrn); + aspectMap.put(INVITE_TOKEN_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(inviteTokenAspect.data()))); + entityResponse.setAspects(aspectMap); + + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); + + Urn roleUrn = _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertNotNull(roleUrn); + assertEquals(roleUrn, this.roleUrn); + } + + @Test + public void getInviteTokenRoleUrnDoesNotExist() throws Exception { + when(_entityClient.exists(eq(roleUrn), eq(SYSTEM_AUTHENTICATION))).thenReturn(false); + + assertThrows(() -> _inviteTokenService.getInviteToken(roleUrn.toString(), false, SYSTEM_AUTHENTICATION)); + } + + @Test + public void getInviteTokenRegenerate() throws Exception { + final SearchResult searchResult = new SearchResult(); + searchResult.setEntities(new SearchEntityArray()); + when(_entityClient.filter(eq(INVITE_TOKEN_ENTITY_NAME), any(), any(), anyInt(), anyInt(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); + when(_secretService.generateUrlSafeToken(anyInt())).thenReturn(INVITE_TOKEN_STRING); + when(_secretService.hashString(anyString())).thenReturn(HASHED_INVITE_TOKEN_STRING); + when(_secretService.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); + + _inviteTokenService.getInviteToken(null, true, SYSTEM_AUTHENTICATION); + verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); + } + + @Test + public void getInviteTokenEmptySearchResult() throws Exception { + final SearchResult searchResult = new SearchResult(); + searchResult.setEntities(new SearchEntityArray()); + when(_entityClient.filter(eq(INVITE_TOKEN_ENTITY_NAME), any(), any(), anyInt(), anyInt(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); + when(_secretService.generateUrlSafeToken(anyInt())).thenReturn(INVITE_TOKEN_STRING); + when(_secretService.hashString(anyString())).thenReturn(HASHED_INVITE_TOKEN_STRING); + when(_secretService.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); + + _inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION); + verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); + } + + @Test + public void getInviteTokenNullEntity() throws Exception { + final SearchResult searchResult = new SearchResult(); + final SearchEntityArray searchEntityArray = new SearchEntityArray(); + final SearchEntity searchEntity = new SearchEntity().setEntity(inviteTokenUrn); + searchEntityArray.add(searchEntity); + searchResult.setEntities(searchEntityArray); + when(_entityClient.filter(eq(INVITE_TOKEN_ENTITY_NAME), any(), any(), anyInt(), anyInt(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(null); + + assertThrows(() -> _inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION)); + } + + @Test + public void getInviteTokenNoInviteTokenAspect() throws Exception { + final SearchResult searchResult = new SearchResult(); + final SearchEntityArray searchEntityArray = new SearchEntityArray(); + final SearchEntity searchEntity = new SearchEntity().setEntity(inviteTokenUrn); + searchEntityArray.add(searchEntity); + searchResult.setEntities(searchEntityArray); + when(_entityClient.filter(eq(INVITE_TOKEN_ENTITY_NAME), any(), any(), anyInt(), anyInt(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); + + final EntityResponse entityResponse = new EntityResponse().setAspects(new EnvelopedAspectMap()); + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); + + when(_secretService.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); + + assertThrows(() -> _inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION)); + } + + @Test + public void getInviteToken() throws Exception { + final SearchResult searchResult = new SearchResult(); + final SearchEntityArray searchEntityArray = new SearchEntityArray(); + final SearchEntity searchEntity = new SearchEntity().setEntity(inviteTokenUrn); + searchEntityArray.add(searchEntity); + searchResult.setEntities(searchEntityArray); + when(_entityClient.filter(eq(INVITE_TOKEN_ENTITY_NAME), any(), any(), anyInt(), anyInt(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); + + final EntityResponse entityResponse = new EntityResponse(); + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + final InviteToken inviteTokenAspect = new InviteToken().setToken(ENCRYPTED_INVITE_TOKEN_STRING).setRole(roleUrn); + aspectMap.put(INVITE_TOKEN_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(inviteTokenAspect.data()))); + entityResponse.setAspects(aspectMap); + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); + + when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN_STRING))).thenReturn(INVITE_TOKEN_STRING); + + assertEquals(_inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION), INVITE_TOKEN_STRING); + } +} diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java index d19de607097c0..b0b10af82155a 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java @@ -7,7 +7,6 @@ import com.linkedin.common.urn.Urn; import com.linkedin.entity.client.EntityClient; import com.linkedin.identity.CorpUserCredentials; -import com.linkedin.identity.InviteToken; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.secret.SecretService; import java.time.Instant; @@ -28,10 +27,11 @@ public class NativeUserServiceTest { private static final String EMAIL = "mock@email.com"; private static final String TITLE = "Data Scientist"; private static final String PASSWORD = "password"; - private static final String INVITE_TOKEN = "inviteToken"; - private static final String ENCRYPTED_INVITE_TOKEN = "encryptedInviteToken"; + private static final String HASHED_PASSWORD = "hashedPassword"; + private static final String ENCRYPTED_INVITE_TOKEN = "encryptedInviteroToken"; private static final String RESET_TOKEN = "inviteToken"; private static final String ENCRYPTED_RESET_TOKEN = "encryptedInviteToken"; + private static final byte[] SALT = "salt".getBytes(); private static final String ENCRYPTED_SALT = "encryptedSalt"; private static final Urn USER_URN = new CorpuserUrn(EMAIL); private static final long ONE_DAY_MILLIS = TimeUnit.DAYS.toMillis(1); @@ -52,71 +52,36 @@ public void setupTest() throws Exception { _nativeUserService = new NativeUserService(_entityService, _entityClient, _secretService); } - @Test - public void testConstructor() throws Exception { - assertThrows(() -> new NativeUserService(null, _entityClient, _secretService)); - assertThrows(() -> new NativeUserService(_entityService, null, _secretService)); - assertThrows(() -> new NativeUserService(_entityService, _entityClient, null)); - - // Succeeds! - new NativeUserService(_entityService, _entityClient, _secretService); - } - @Test public void testCreateNativeUserNullArguments() { - assertThrows(() -> _nativeUserService.createNativeUser(null, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN, + assertThrows( + () -> _nativeUserService.createNativeUser(null, FULL_NAME, EMAIL, TITLE, PASSWORD, SYSTEM_AUTHENTICATION)); + assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, null, EMAIL, TITLE, PASSWORD, SYSTEM_AUTHENTICATION)); - assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, null, EMAIL, TITLE, PASSWORD, INVITE_TOKEN, + assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, null, TITLE, PASSWORD, SYSTEM_AUTHENTICATION)); - assertThrows( - () -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, null, TITLE, PASSWORD, INVITE_TOKEN, - SYSTEM_AUTHENTICATION)); - assertThrows( - () -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, null, PASSWORD, INVITE_TOKEN, - SYSTEM_AUTHENTICATION)); - assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, null, INVITE_TOKEN, + assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, null, PASSWORD, SYSTEM_AUTHENTICATION)); - assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, null, + assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, null, SYSTEM_AUTHENTICATION)); } - @Test(expectedExceptions = RuntimeException.class, - expectedExceptionsMessageRegExp = "Invalid sign-up token. Please ask your administrator to send you an updated link!") - public void testCreateNativeUserInviteTokenDoesNotExist() throws Exception { - // Nonexistent invite token - when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(null); - - _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN, - SYSTEM_AUTHENTICATION); - } - @Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = "This user already exists! Cannot create a new user.") public void testCreateNativeUserUserAlreadyExists() throws Exception { - InviteToken mockInviteTokenAspect = mock(InviteToken.class); - when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect); - when(mockInviteTokenAspect.hasToken()).thenReturn(true); - when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN); - when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN); - // The user already exists when(_entityService.exists(any())).thenReturn(true); - _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN, - SYSTEM_AUTHENTICATION); + _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, SYSTEM_AUTHENTICATION); } @Test public void testCreateNativeUserPasses() throws Exception { - InviteToken mockInviteTokenAspect = mock(InviteToken.class); - when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect); - when(mockInviteTokenAspect.hasToken()).thenReturn(true); - when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN); when(_entityService.exists(any())).thenReturn(false); - when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN); + when(_secretService.generateSalt(anyInt())).thenReturn(SALT); when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT); + when(_secretService.getHashedPassword(any(), any())).thenReturn(HASHED_PASSWORD); - _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN, - SYSTEM_AUTHENTICATION); + _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, SYSTEM_AUTHENTICATION); } @Test @@ -133,42 +98,14 @@ public void testUpdateCorpUserStatusPasses() throws Exception { @Test public void testUpdateCorpUserCredentialsPasses() throws Exception { + when(_secretService.generateSalt(anyInt())).thenReturn(SALT); when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT); + when(_secretService.getHashedPassword(any(), any())).thenReturn(HASHED_PASSWORD); _nativeUserService.updateCorpUserCredentials(USER_URN, PASSWORD, SYSTEM_AUTHENTICATION); verify(_entityClient).ingestProposal(any(), any()); } - @Test - public void testGenerateNativeUserInviteTokenPasses() throws Exception { - when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN); - - _nativeUserService.generateNativeUserInviteToken(SYSTEM_AUTHENTICATION); - verify(_entityClient).ingestProposal(any(), any()); - } - - @Test - public void testGetNativeUserInviteTokenInviteTokenDoesNotExistPasses() throws Exception { - // Nonexistent invite token - when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(null); - when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN); - - _nativeUserService.getNativeUserInviteToken(SYSTEM_AUTHENTICATION); - verify(_entityClient).ingestProposal(any(), any()); - } - - @Test - public void testGetNativeUserInviteTokenPasses() throws Exception { - InviteToken mockInviteTokenAspect = mock(InviteToken.class); - when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect); - when(_entityService.exists(any())).thenReturn(false); - when(mockInviteTokenAspect.hasToken()).thenReturn(true); - when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN); - when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN); - - assertEquals(_nativeUserService.getNativeUserInviteToken(SYSTEM_AUTHENTICATION), INVITE_TOKEN); - } - @Test public void testGenerateNativeUserResetTokenNullArguments() { assertThrows(() -> _nativeUserService.generateNativeUserPasswordResetToken(null, SYSTEM_AUTHENTICATION)); @@ -268,6 +205,7 @@ public void testResetCorpUserCredentialsPasses() throws Exception { when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn( Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli()); when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn(RESET_TOKEN); + when(_secretService.generateSalt(anyInt())).thenReturn(SALT); when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT); _nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION); diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/RoleServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/RoleServiceTest.java new file mode 100644 index 0000000000000..41fbcfbe649ae --- /dev/null +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/RoleServiceTest.java @@ -0,0 +1,65 @@ +package com.datahub.authorization; + +import com.datahub.authentication.Actor; +import com.datahub.authentication.ActorType; +import com.datahub.authentication.Authentication; +import com.datahub.authorization.role.RoleService; +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.client.EntityClient; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class RoleServiceTest { + private static final String ROLE_URN_STRING = "urn:li:dataHubRole:Admin"; + private static final String ACTOR_URN_STRING = "urn:li:corpuser:foo"; + private static final String DATAHUB_SYSTEM_CLIENT_ID = "__datahub_system"; + private static final Authentication SYSTEM_AUTHENTICATION = + new Authentication(new Actor(ActorType.USER, DATAHUB_SYSTEM_CLIENT_ID), ""); + private Urn roleUrn; + private EntityClient _entityClient; + private RoleService _roleService; + + @BeforeMethod + public void setupTest() throws Exception { + roleUrn = Urn.createFromString(ROLE_URN_STRING); + _entityClient = mock(EntityClient.class); + + _roleService = new RoleService(_entityClient); + } + + @Test + public void testRoleExists() throws Exception { + when(_entityClient.exists(eq(roleUrn), eq(SYSTEM_AUTHENTICATION))).thenReturn(true); + assertTrue(_roleService.exists(Urn.createFromString(ROLE_URN_STRING), SYSTEM_AUTHENTICATION)); + } + + @Test + public void testRoleDoesNotExist() throws Exception { + when(_entityClient.exists(eq(roleUrn), eq(SYSTEM_AUTHENTICATION))).thenReturn(false); + assertFalse(_roleService.exists(Urn.createFromString(ROLE_URN_STRING), SYSTEM_AUTHENTICATION)); + } + + @Test + public void testAssignRoleToActorDoesNotExist() throws Exception { + when(_entityClient.exists(eq(Urn.createFromString(ACTOR_URN_STRING)), eq(SYSTEM_AUTHENTICATION))).thenReturn( + false); + + _roleService.assignRoleToActor(ACTOR_URN_STRING, Urn.createFromString(ROLE_URN_STRING), + SYSTEM_AUTHENTICATION); + verify(_entityClient, never()).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); + } + + @Test + public void testAssignRoleToActorExists() throws Exception { + when(_entityClient.exists(eq(Urn.createFromString(ACTOR_URN_STRING)), eq(SYSTEM_AUTHENTICATION))).thenReturn( + true); + + _roleService.assignRoleToActor(ACTOR_URN_STRING, Urn.createFromString(ROLE_URN_STRING), + SYSTEM_AUTHENTICATION); + verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); + } +} diff --git a/metadata-service/auth-servlet-impl/src/main/java/com/datahub/authentication/AuthServiceController.java b/metadata-service/auth-servlet-impl/src/main/java/com/datahub/authentication/AuthServiceController.java index 1e694024818e3..4f826e3ae2bce 100644 --- a/metadata-service/auth-servlet-impl/src/main/java/com/datahub/authentication/AuthServiceController.java +++ b/metadata-service/auth-servlet-impl/src/main/java/com/datahub/authentication/AuthServiceController.java @@ -1,11 +1,13 @@ package com.datahub.authentication; +import com.datahub.authentication.invite.InviteTokenService; import com.datahub.authentication.token.StatelessTokenService; import com.datahub.authentication.token.TokenType; import com.datahub.authentication.user.NativeUserService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.common.urn.Urn; import com.linkedin.gms.factory.config.ConfigurationProvider; import java.util.concurrent.CompletableFuture; import javax.inject.Inject; @@ -49,6 +51,9 @@ public class AuthServiceController { @Inject NativeUserService _nativeUserService; + @Inject + InviteTokenService _inviteTokenService; + /** * Generates a JWT access token for as user UI session, provided a unique "user id" to generate the token for inside a JSON * POST body. @@ -166,11 +171,17 @@ CompletableFuture> signUp(final HttpEntity httpEn String passwordString = password.asText(); String inviteTokenString = inviteToken.asText(); Authentication auth = AuthenticationContext.getAuthentication(); - log.debug(String.format("Attempting to create credentials for native user %s", userUrnString)); + log.debug(String.format("Attempting to create native user %s", userUrnString)); return CompletableFuture.supplyAsync(() -> { try { + Urn inviteTokenUrn = _inviteTokenService.getInviteTokenUrn(inviteTokenString); + if (!_inviteTokenService.isInviteTokenValid(inviteTokenUrn, auth)) { + log.error(String.format("Invalid invite token %s", inviteTokenString)); + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + _nativeUserService.createNativeUser(userUrnString, fullNameString, emailString, titleString, passwordString, - inviteTokenString, auth); + auth); String response = buildSignUpResponse(); return new ResponseEntity<>(response, HttpStatus.OK); } catch (Exception e) { diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java new file mode 100644 index 0000000000000..47f7ef0e0c1eb --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java @@ -0,0 +1,33 @@ +package com.linkedin.gms.factory.auth; + +import com.datahub.authentication.invite.InviteTokenService; +import com.linkedin.gms.factory.spring.YamlPropertySourceFactory; +import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.metadata.secret.SecretService; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.context.annotation.Scope; + + +@Configuration +@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +public class InviteTokenServiceFactory { + @Autowired + @Qualifier("javaEntityClient") + private JavaEntityClient _javaEntityClient; + + @Autowired + @Qualifier("dataHubSecretService") + private SecretService _secretService; + + @Bean(name = "inviteTokenService") + @Scope("singleton") + @Nonnull + protected InviteTokenService getInstance() throws Exception { + return new InviteTokenService(this._javaEntityClient, this._secretService); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java new file mode 100644 index 0000000000000..42f3e797c33bd --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java @@ -0,0 +1,31 @@ + + +package com.linkedin.gms.factory.auth; + +import com.datahub.authorization.role.RoleService; +import com.linkedin.gms.factory.spring.YamlPropertySourceFactory; +import com.linkedin.metadata.client.JavaEntityClient; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.context.annotation.Scope; + + +@Configuration +@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +public class RoleServiceFactory { + + @Autowired + @Qualifier("javaEntityClient") + private JavaEntityClient _javaEntityClient; + + @Bean(name = "roleService") + @Scope("singleton") + @Nonnull + protected RoleService getInstance() throws Exception { + return new RoleService(this._javaEntityClient); + } +} \ No newline at end of file diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java index 5c4a1e1b9ddc9..5f50233c6a486 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java @@ -1,8 +1,10 @@ package com.linkedin.gms.factory.graphql; import com.datahub.authentication.group.GroupService; +import com.datahub.authentication.invite.InviteTokenService; import com.datahub.authentication.token.StatefulTokenService; import com.datahub.authentication.user.NativeUserService; +import com.datahub.authorization.role.RoleService; import com.linkedin.datahub.graphql.GmsGraphQLEngine; import com.linkedin.datahub.graphql.GraphQLEngine; import com.linkedin.datahub.graphql.analytics.service.AnalyticsService; @@ -113,6 +115,14 @@ public class GraphQLEngineFactory { @Qualifier("groupService") private GroupService _groupService; + @Autowired + @Qualifier("roleService") + private RoleService _roleService; + + @Autowired + @Qualifier("inviteTokenService") + private InviteTokenService _inviteTokenService; + @Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED private Boolean isAnalyticsEnabled; @@ -145,6 +155,8 @@ protected GraphQLEngine getInstance() { _configProvider.getDatahub(), _siblingGraphService, _groupService, + _roleService, + _inviteTokenService, _configProvider.getFeatureFlags() ).builder().build(); } @@ -172,6 +184,8 @@ protected GraphQLEngine getInstance() { _configProvider.getDatahub(), _siblingGraphService, _groupService, + _roleService, + _inviteTokenService, _configProvider.getFeatureFlags() ).builder().build(); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java index 310697546c861..3e26a2e6e8362 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java @@ -74,7 +74,7 @@ protected BootstrapManager createInstance() { final IngestRootUserStep ingestRootUserStep = new IngestRootUserStep(_entityService); final IngestPoliciesStep ingestPoliciesStep = new IngestPoliciesStep(_entityRegistry, _entityService, _entitySearchService, _searchDocumentTransformer); - final IngestRolesStep ingestRolesStep = new IngestRolesStep(_entityService); + final IngestRolesStep ingestRolesStep = new IngestRolesStep(_entityService, _entityRegistry); final IngestDataPlatformsStep ingestDataPlatformsStep = new IngestDataPlatformsStep(_entityService); final IngestDataPlatformInstancesStep ingestDataPlatformInstancesStep = new IngestDataPlatformInstancesStep(_entityService, _migrationsDao); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java index 823e468e4dc4d..ff441caf0c5b2 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java @@ -6,9 +6,11 @@ import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.Constants; import com.linkedin.metadata.boot.BootstrapStep; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.GenericAspect; @@ -28,6 +30,7 @@ public class IngestRolesStep implements BootstrapStep { private static final int SLEEP_SECONDS = 60; private final EntityService _entityService; + private final EntityRegistry _entityRegistry; @Override public String name() { @@ -58,6 +61,11 @@ public void execute() throws Exception { String.format("Found malformed roles file, expected an Array but found %s", rolesObj.getNodeType())); } + final AspectSpec roleInfoAspectSpec = + _entityRegistry.getEntitySpec(DATAHUB_ROLE_ENTITY_NAME).getAspectSpec(DATAHUB_ROLE_INFO_ASPECT_NAME); + final AuditStamp auditStamp = + new AuditStamp().setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)).setTime(System.currentTimeMillis()); + for (final JsonNode roleObj : rolesObj) { final Urn urn = Urn.createFromString(roleObj.get("urn").asText()); @@ -68,35 +76,39 @@ public void execute() throws Exception { } final DataHubRoleInfo info = RecordUtils.toRecordTemplate(DataHubRoleInfo.class, roleObj.get("info").toString()); - ingestRole(urn, info); + ingestRole(urn, info, auditStamp, roleInfoAspectSpec); } log.info("Successfully ingested default Roles."); } - private void ingestRole(final Urn urn, final DataHubRoleInfo info) throws URISyntaxException { + private void ingestRole(final Urn roleUrn, final DataHubRoleInfo dataHubRoleInfo, final AuditStamp auditStamp, + final AspectSpec roleInfoAspectSpec) throws URISyntaxException { // 3. Write key & aspect final MetadataChangeProposal keyAspectProposal = new MetadataChangeProposal(); - final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(urn); + final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(roleUrn); GenericAspect aspect = - GenericRecordUtils.serializeAspect(EntityKeyUtils.convertUrnToEntityKey(urn, keyAspectSpec)); + GenericRecordUtils.serializeAspect(EntityKeyUtils.convertUrnToEntityKey(roleUrn, keyAspectSpec)); keyAspectProposal.setAspect(aspect); keyAspectProposal.setAspectName(keyAspectSpec.getName()); keyAspectProposal.setEntityType(DATAHUB_ROLE_ENTITY_NAME); keyAspectProposal.setChangeType(ChangeType.UPSERT); - keyAspectProposal.setEntityUrn(urn); + keyAspectProposal.setEntityUrn(roleUrn); _entityService.ingestProposal(keyAspectProposal, new AuditStamp().setActor(Urn.createFromString(SYSTEM_ACTOR)).setTime(System.currentTimeMillis())); final MetadataChangeProposal proposal = new MetadataChangeProposal(); - proposal.setEntityUrn(urn); + proposal.setEntityUrn(roleUrn); proposal.setEntityType(DATAHUB_ROLE_ENTITY_NAME); proposal.setAspectName(DATAHUB_ROLE_INFO_ASPECT_NAME); - proposal.setAspect(GenericRecordUtils.serializeAspect(info)); + proposal.setAspect(GenericRecordUtils.serializeAspect(dataHubRoleInfo)); proposal.setChangeType(ChangeType.UPSERT); _entityService.ingestProposal(proposal, new AuditStamp().setActor(Urn.createFromString(SYSTEM_ACTOR)).setTime(System.currentTimeMillis())); + + _entityService.produceMetadataChangeLog(roleUrn, DATAHUB_ROLE_ENTITY_NAME, DATAHUB_ROLE_INFO_ASPECT_NAME, + roleInfoAspectSpec, null, dataHubRoleInfo, null, null, auditStamp, ChangeType.RESTATE); } } diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json index fbb9e8ac2f5db..faf814ef43fe8 100644 --- a/metadata-service/war/src/main/resources/boot/policies.json +++ b/metadata-service/war/src/main/resources/boot/policies.json @@ -78,7 +78,6 @@ "users":[] }, "privileges":[ - "MANAGE_POLICIES", "MANAGE_INGESTION", "MANAGE_SECRETS", "MANAGE_USERS_AND_GROUPS", @@ -89,7 +88,7 @@ "MANAGE_GLOSSARIES", "MANAGE_TAGS" ], - "displayName":"All Users - All Platform Privileges", + "displayName":"All Users - All Platform Privileges (EXCEPT MANAGE POLICIES)", "description":"Grants full platform privileges to ALL users of DataHub. Change this policy to alter that behavior.", "state":"ACTIVE", "type":"PLATFORM", diff --git a/smoke-test/test_e2e.py b/smoke-test/test_e2e.py index 3aae802d9f209..4b313bc3ff73a 100644 --- a/smoke-test/test_e2e.py +++ b/smoke-test/test_e2e.py @@ -1236,11 +1236,12 @@ def test_native_user_endpoints(frontend_session): # Test getting the invite token get_invite_token_json = { - "query": """query getNativeUserInviteToken {\n - getNativeUserInviteToken{\n + "query": """query getInviteToken($input: GetInviteTokenInput!) {\n + getInviteToken(input: $input){\n inviteToken\n }\n - }""" + }""", + "variables": {"input": {}}, } get_invite_token_response = frontend_session.post( @@ -1251,9 +1252,7 @@ def test_native_user_endpoints(frontend_session): assert get_invite_token_res_data assert get_invite_token_res_data["data"] - invite_token = get_invite_token_res_data["data"]["getNativeUserInviteToken"][ - "inviteToken" - ] + invite_token = get_invite_token_res_data["data"]["getInviteToken"]["inviteToken"] assert invite_token is not None assert "errors" not in get_invite_token_res_data @@ -1386,10 +1385,7 @@ def test_native_user_endpoints(frontend_session): assert unauthenticated_get_invite_token_res_data assert "errors" in unauthenticated_get_invite_token_res_data assert unauthenticated_get_invite_token_res_data["data"] - assert ( - unauthenticated_get_invite_token_res_data["data"]["getNativeUserInviteToken"] - is None - ) + assert unauthenticated_get_invite_token_res_data["data"]["getInviteToken"] is None unauthenticated_create_reset_token_json = { "query": """mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {\n diff --git a/smoke-test/tests/tokens/revokable_access_token_test.py b/smoke-test/tests/tokens/revokable_access_token_test.py index 915b1123f77a9..2ce6bb4ad503d 100644 --- a/smoke-test/tests/tokens/revokable_access_token_test.py +++ b/smoke-test/tests/tokens/revokable_access_token_test.py @@ -3,7 +3,11 @@ import requests from time import sleep -from tests.utils import get_frontend_url, wait_for_healthcheck_util, get_admin_credentials +from tests.utils import ( + get_frontend_url, + wait_for_healthcheck_util, + get_admin_credentials, +) # Disable telemetry @@ -17,81 +21,89 @@ def wait_for_healthchecks(): wait_for_healthcheck_util() yield + @pytest.mark.dependency() def test_healthchecks(wait_for_healthchecks): # Call to wait_for_healthchecks fixture will do the actual functionality. pass + @pytest.mark.dependency(depends=["test_healthchecks"]) -@pytest.fixture(scope='class', autouse=True) +@pytest.fixture(scope="class", autouse=True) def custom_user_setup(): - """Fixture to execute setup before and tear down after all tests are run""" - admin_session = loginAs(admin_user, admin_pass) - - res_data = removeUser(admin_session, "urn:li:corpuser:user") - assert res_data - assert "error" not in res_data - - # Test getting the invite token - get_invite_token_json = { - "query": """query getNativeUserInviteToken {\n - getNativeUserInviteToken{\n - inviteToken\n - }\n - }""" - } - - get_invite_token_response = admin_session.post(f"{get_frontend_url()}/api/v2/graphql", json=get_invite_token_json) - get_invite_token_response.raise_for_status() - get_invite_token_res_data = get_invite_token_response.json() - - assert get_invite_token_res_data - assert get_invite_token_res_data["data"] - invite_token = get_invite_token_res_data["data"]["getNativeUserInviteToken"]["inviteToken"] - assert invite_token is not None - assert "error" not in invite_token - - # Pass the invite token when creating the user - sign_up_json = { - "fullName": "Test User", - "email": "user", - "password": "user", - "title": "Date Engineer", - "inviteToken": invite_token - } - - sign_up_response = admin_session.post(f"{get_frontend_url()}/signUp", json=sign_up_json) - sign_up_response.raise_for_status() - assert sign_up_response - assert "error" not in sign_up_response - # Sleep for eventual consistency - sleep(3) - - # signUp will override the session cookie to the new user to be signed up. - admin_session.cookies.clear() - admin_session = loginAs(admin_user, admin_pass) - - # Make user created user is there. - res_data = listUsers(admin_session) - assert res_data["data"] - assert res_data["data"]["listUsers"] - assert {'username': 'user'} in res_data["data"]["listUsers"]["users"] - - yield - - # Delete created user - res_data = removeUser(admin_session, "urn:li:corpuser:user") - assert res_data - assert res_data['data'] - assert res_data['data']['removeUser'] == True - # Sleep for eventual consistency - sleep(3) - - # Make user created user is not there. - res_data = listUsers(admin_session) - assert res_data["data"] - assert res_data["data"]["listUsers"] - assert {'username': 'user'} not in res_data["data"]["listUsers"]["users"] + """Fixture to execute setup before and tear down after all tests are run""" + admin_session = loginAs(admin_user, admin_pass) + + res_data = removeUser(admin_session, "urn:li:corpuser:user") + assert res_data + assert "error" not in res_data + + # Test getting the invite token + get_invite_token_json = { + "query": """query getInviteToken($input: GetInviteTokenInput!) {\n + getInviteToken(input: $input){\n + inviteToken\n + }\n + }""", + "variables": {"input": {}}, + } + + get_invite_token_response = admin_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=get_invite_token_json + ) + get_invite_token_response.raise_for_status() + get_invite_token_res_data = get_invite_token_response.json() + + assert get_invite_token_res_data + assert get_invite_token_res_data["data"] + invite_token = get_invite_token_res_data["data"]["getInviteToken"]["inviteToken"] + assert invite_token is not None + assert "error" not in invite_token + + # Pass the invite token when creating the user + sign_up_json = { + "fullName": "Test User", + "email": "user", + "password": "user", + "title": "Date Engineer", + "inviteToken": invite_token, + } + + sign_up_response = admin_session.post( + f"{get_frontend_url()}/signUp", json=sign_up_json + ) + sign_up_response.raise_for_status() + assert sign_up_response + assert "error" not in sign_up_response + # Sleep for eventual consistency + sleep(3) + + # signUp will override the session cookie to the new user to be signed up. + admin_session.cookies.clear() + admin_session = loginAs(admin_user, admin_pass) + + # Make user created user is there. + res_data = listUsers(admin_session) + assert res_data["data"] + assert res_data["data"]["listUsers"] + assert {"username": "user"} in res_data["data"]["listUsers"]["users"] + + yield + + # Delete created user + res_data = removeUser(admin_session, "urn:li:corpuser:user") + assert res_data + assert res_data["data"] + assert res_data["data"]["removeUser"] == True + # Sleep for eventual consistency + sleep(3) + + # Make user created user is not there. + res_data = listUsers(admin_session) + assert res_data["data"] + assert res_data["data"]["listUsers"] + assert {"username": "user"} not in res_data["data"]["listUsers"]["users"] + @pytest.mark.dependency(depends=["test_healthchecks"]) @pytest.fixture(autouse=True) @@ -115,6 +127,7 @@ def access_token_setup(): # Sleep for eventual consistency sleep(3) + @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): admin_session = loginAs(admin_user, admin_pass) @@ -132,7 +145,10 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): assert res_data["data"] assert res_data["data"]["createAccessToken"] assert res_data["data"]["createAccessToken"]["accessToken"] - assert res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] == f"urn:li:corpuser:{admin_user}" + assert ( + res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] + == f"urn:li:corpuser:{admin_user}" + ) admin_tokenId = res_data["data"]["createAccessToken"]["metadata"]["id"] # Sleep for eventual consistency sleep(3) @@ -143,8 +159,14 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 1 - assert res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] == f"urn:li:corpuser:{admin_user}" - assert res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] == f"urn:li:corpuser:{admin_user}" + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] + == f"urn:li:corpuser:{admin_user}" + ) + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] + == f"urn:li:corpuser:{admin_user}" + ) # Check that the super account can revoke tokens that it created res_data = revokeAccessToken(admin_session, admin_tokenId) @@ -162,6 +184,7 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 + @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks): admin_session = loginAs(admin_user, admin_pass) @@ -179,7 +202,10 @@ def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks assert res_data["data"] assert res_data["data"]["createAccessToken"] assert res_data["data"]["createAccessToken"]["accessToken"] - assert res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] == "urn:li:corpuser:user" + assert ( + res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] + == "urn:li:corpuser:user" + ) user_tokenId = res_data["data"]["createAccessToken"]["metadata"]["id"] # Sleep for eventual consistency sleep(3) @@ -190,8 +216,14 @@ def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 1 - assert res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] == "urn:li:corpuser:user" - assert res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] == f"urn:li:corpuser:{admin_user}" + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] + == "urn:li:corpuser:user" + ) + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] + == f"urn:li:corpuser:{admin_user}" + ) # Check that the super account can revoke tokens that it created for another user res_data = revokeAccessToken(admin_session, user_tokenId) @@ -209,6 +241,7 @@ def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 + @pytest.mark.dependency(depends=["test_healthchecks"]) def test_non_admin_can_create_list_revoke_tokens(wait_for_healthchecks): user_session = loginAs("user", "user") @@ -219,19 +252,30 @@ def test_non_admin_can_create_list_revoke_tokens(wait_for_healthchecks): assert res_data["data"] assert res_data["data"]["createAccessToken"] assert res_data["data"]["createAccessToken"]["accessToken"] - assert res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] == "urn:li:corpuser:user" + assert ( + res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] + == "urn:li:corpuser:user" + ) user_tokenId = res_data["data"]["createAccessToken"]["metadata"]["id"] # Sleep for eventual consistency sleep(3) # User should be able to list his own token - res_data = listAccessTokens(user_session, [{"field": "ownerUrn","value": "urn:li:corpuser:user"}]) + res_data = listAccessTokens( + user_session, [{"field": "ownerUrn", "value": "urn:li:corpuser:user"}] + ) assert res_data assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 1 - assert res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] == "urn:li:corpuser:user" - assert res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] == "urn:li:corpuser:user" + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] + == "urn:li:corpuser:user" + ) + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] + == "urn:li:corpuser:user" + ) assert res_data["data"]["listAccessTokens"]["tokens"][0]["id"] == user_tokenId # User should be able to revoke his own token @@ -244,12 +288,15 @@ def test_non_admin_can_create_list_revoke_tokens(wait_for_healthchecks): sleep(3) # Using a normal account, check that all its tokens where removed. - res_data = listAccessTokens(user_session, [{"field": "ownerUrn","value": "urn:li:corpuser:user"}]) + res_data = listAccessTokens( + user_session, [{"field": "ownerUrn", "value": "urn:li:corpuser:user"}] + ) assert res_data assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 + @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): admin_session = loginAs(admin_user, admin_pass) @@ -268,8 +315,14 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): assert res_data["data"] assert res_data["data"]["createAccessToken"] assert res_data["data"]["createAccessToken"]["accessToken"] - assert res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] == "urn:li:corpuser:user" - assert res_data["data"]["createAccessToken"]["metadata"]["ownerUrn"] == "urn:li:corpuser:user" + assert ( + res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] + == "urn:li:corpuser:user" + ) + assert ( + res_data["data"]["createAccessToken"]["metadata"]["ownerUrn"] + == "urn:li:corpuser:user" + ) user_tokenId = res_data["data"]["createAccessToken"]["metadata"]["id"] # Sleep for eventual consistency sleep(3) @@ -277,13 +330,21 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): # Admin should be able to list other tokens user_session.cookies.clear() admin_session = loginAs(admin_user, admin_pass) - res_data = listAccessTokens(admin_session, [{"field": "ownerUrn","value": "urn:li:corpuser:user"}]) + res_data = listAccessTokens( + admin_session, [{"field": "ownerUrn", "value": "urn:li:corpuser:user"}] + ) assert res_data assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 1 - assert res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] == "urn:li:corpuser:user" - assert res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] == "urn:li:corpuser:user" + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] + == "urn:li:corpuser:user" + ) + assert ( + res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] + == "urn:li:corpuser:user" + ) assert res_data["data"]["listAccessTokens"]["tokens"][0]["id"] == user_tokenId # Admin can delete token created by someone else. @@ -300,7 +361,9 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): # Using a normal account, check that all its tokens where removed. user_session.cookies.clear() user_session = loginAs("user", "user") - res_data = listAccessTokens(user_session, [{"field": "ownerUrn","value": "urn:li:corpuser:user"}]) + res_data = listAccessTokens( + user_session, [{"field": "ownerUrn", "value": "urn:li:corpuser:user"}] + ) assert res_data assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None @@ -308,12 +371,15 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): # Using the super account, check that all tokens where removed. admin_session = loginAs(admin_user, admin_pass) - res_data = listAccessTokens(admin_session, [{"field": "ownerUrn","value": "urn:li:corpuser:user"}]) + res_data = listAccessTokens( + admin_session, [{"field": "ownerUrn", "value": "urn:li:corpuser:user"}] + ) assert res_data assert res_data["data"] assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 + @pytest.mark.dependency(depends=["test_healthchecks"]) def test_non_admin_can_not_generate_tokens_for_others(wait_for_healthchecks): user_session = loginAs("user", "user") @@ -321,7 +387,11 @@ def test_non_admin_can_not_generate_tokens_for_others(wait_for_healthchecks): res_data = generateAccessToken_v2(user_session, f"urn:li:corpuser:{admin_user}") assert res_data assert res_data["errors"] - assert res_data["errors"][0]["message"] == "Unauthorized to perform this action. Please contact your DataHub administrator." + assert ( + res_data["errors"][0]["message"] + == "Unauthorized to perform this action. Please contact your DataHub administrator." + ) + def generateAccessToken_v2(session, actorUrn): # Create new token @@ -419,14 +489,10 @@ def removeUser(session, urn): "query": """mutation removeUser($urn: String!) {\n removeUser(urn: $urn) }""", - "variables": { - "urn": urn - } + "variables": {"urn": urn}, } - response = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=json - ) + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) response.raise_for_status() return response.json() @@ -434,8 +500,8 @@ def removeUser(session, urn): def listUsers(session): input = { - "start": "0", - "count": "20", + "start": "0", + "count": "20", } # list users @@ -450,14 +516,10 @@ def listUsers(session): } } }""", - "variables": { - "input": input - } + "variables": {"input": input}, } - response = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=json - ) + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) response.raise_for_status() return response.json()