From 262c9c5fa7131dc946ae784193d577746f5e6ee9 Mon Sep 17 00:00:00 2001 From: aditya-radhakrishnan Date: Tue, 6 Sep 2022 13:37:55 -0700 Subject: [PATCH 1/6] feat(roles): add ability to invite users into a role --- .../app/client/AuthServiceClient.java | 4 +- .../controllers/AuthenticationController.java | 12 +- .../datahub/graphql/GmsGraphQLEngine.java | 36 +-- .../resolvers/role/AcceptRoleResolver.java | 52 +++++ .../role/BatchAssignRoleResolver.java | 38 +--- .../role/CreateInviteTokenResolver.java | 48 ++++ .../role/GetInviteTokenResolver.java | 48 ++++ .../resolvers/role/ListRolesResolver.java | 6 +- .../src/main/resources/entity.graphql | 45 ++++ .../role/AcceptRoleResolverTest.java | 105 +++++++++ .../role/BatchAssignRoleResolverTest.java | 30 +-- .../role/CreateInviteTokenResolverTest.java | 54 +++++ .../role/GetInviteTokenResolverTest.java | 54 +++++ datahub-web-react/src/app/auth/SignUp.tsx | 35 ++- .../src/app/identity/user/UserList.tsx | 12 +- .../identity/user/ViewInviteTokenModal.tsx | 135 +++++++++-- .../src/app/permissions/ManagePermissions.tsx | 2 +- .../src/app/permissions/roles/ManageRoles.tsx | 55 +++-- .../roles/ViewRoleInviteTokenModal.tsx | 119 ++++++++++ .../src/graphql/mutations.graphql | 10 + datahub-web-react/src/graphql/role.graphql | 6 + .../java/com/linkedin/metadata/Constants.java | 3 + .../com/linkedin/identity/InviteToken.pdl | 12 +- .../src/main/resources/entity-registry.yml | 1 + .../invite/InviteTokenService.java | 200 ++++++++++++++++ .../user/NativeUserService.java | 28 +-- .../authorization/role/RoleService.java | 53 +++++ .../invite/InviteTokenServiceTest.java | 213 ++++++++++++++++++ .../user/NativeUserServiceTest.java | 43 +--- .../authorization/RoleServiceTest.java | 65 ++++++ .../authentication/AuthServiceController.java | 15 +- .../auth/InviteTokenServiceFactory.java | 33 +++ .../gms/factory/auth/RoleServiceFactory.java | 31 +++ .../factory/graphql/GraphQLEngineFactory.java | 14 ++ .../factories/BootstrapManagerFactory.java | 2 +- .../metadata/boot/steps/IngestRolesStep.java | 28 ++- 36 files changed, 1454 insertions(+), 193 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolver.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolverTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolverTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolverTest.java create mode 100644 datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx create mode 100644 metadata-service/auth-impl/src/main/java/com/datahub/authentication/invite/InviteTokenService.java create mode 100644 metadata-service/auth-impl/src/main/java/com/datahub/authorization/role/RoleService.java create mode 100644 metadata-service/auth-impl/src/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java create mode 100644 metadata-service/auth-impl/src/test/java/com/datahub/authorization/RoleServiceTest.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java 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..ddec29750a493 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; @@ -305,6 +310,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 +376,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 +407,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); @@ -675,6 +678,7 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("entity", getEntityResolver()) .dataFetcher("entities", getEntitiesResolver()) .dataFetcher("listRoles", new ListRolesResolver(this.entityClient)) + .dataFetcher("getInviteToken", new GetInviteTokenResolver(this.inviteTokenService)) ); } @@ -795,7 +799,9 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .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..192cbbb7d5bab --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolver.java @@ -0,0 +1,52 @@ +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.Optional; +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)); + } + + Optional roleUrnOptional = _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, authentication); + if (roleUrnOptional.isPresent()) { + _roleService.assignRoleToActor(authentication.getActor().toUrnStr(), roleUrnOptional.get(), authentication); + } + + return true; + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to perform update against input %s", input), 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..514d07f1aa24e --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolver.java @@ -0,0 +1,48 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +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.CreateInviteTokenInput; +import com.linkedin.datahub.graphql.generated.InviteToken; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Optional; +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 { + Optional optionalRoleUrn = + roleUrnStr == null ? Optional.empty() : Optional.of(Urn.createFromString(roleUrnStr)); + return new InviteToken(_inviteTokenService.getInviteToken(optionalRoleUrn, true, authentication)); + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to perform update against input %s", input), 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..caf85dc12c05b --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolver.java @@ -0,0 +1,48 @@ +package com.linkedin.datahub.graphql.resolvers.role; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +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.GetInviteTokenInput; +import com.linkedin.datahub.graphql.generated.InviteToken; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Optional; +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 { + Optional optionalRoleUrn = + roleUrnStr == null ? Optional.empty() : Optional.of(Urn.createFromString(roleUrnStr)); + return new InviteToken(_inviteTokenService.getInviteToken(optionalRoleUrn, false, authentication)); + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to perform update against input %s", input), 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/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 4c5eb89218b53..0bedd48a85a11 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -183,6 +183,11 @@ type Query { List all DataHub Roles """ listRoles(input: ListRolesInput!): ListRolesResult + + """ + Get invite token + """ + getInviteToken(input: GetInviteTokenInput!): InviteToken } """ @@ -513,6 +518,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 +9360,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..9aa8ee741b2f5 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/AcceptRoleResolverTest.java @@ -0,0 +1,105 @@ +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 java.util.Optional; +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.getRoleUrnFromInviteToken(eq(inviteTokenUrn), eq(_authentication))).thenReturn( + Optional.empty()); + + 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.getRoleUrnFromInviteToken(eq(inviteTokenUrn), eq(_authentication))).thenReturn( + Optional.of(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/role/GetInviteTokenResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolverTest.java new file mode 100644 index 0000000000000..ef426979953d0 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/role/GetInviteTokenResolverTest.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.GetInviteTokenInput; +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 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() throws Exception { + _inviteTokenService = mock(InviteTokenService.class); + _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); + _authentication = mock(Authentication.class); + + _resolver = new GetInviteTokenResolver(_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(false), eq(_authentication))).thenReturn(INVITE_TOKEN_STRING); + + GetInviteTokenInput input = new GetInviteTokenInput(); + 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-web-react/src/app/auth/SignUp.tsx b/datahub-web-react/src/app/auth/SignUp.tsx index 12791855c0aab..6330c70c868ad 100644 --- a/datahub-web-react/src/app/auth/SignUp.tsx +++ b/datahub-web-react/src/app/auth/SignUp.tsx @@ -1,4 +1,4 @@ -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'; @@ -11,6 +11,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; @@ -98,6 +99,38 @@ export const SignUp: React.VFC = () => { [refreshContext, inviteToken], ); + 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, + }); + }); + }; + + useEffect(() => { + if (isLoggedIn) { + acceptRole(); + } + }); + if (isLoggedIn && !loading) { return ; } 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..3fa42e188231b 100644 --- a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx +++ b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx @@ -1,12 +1,14 @@ -import { RedoOutlined } from '@ant-design/icons'; -import { Button, Modal, Typography } from 'antd'; -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import * as QueryString from 'query-string'; +import { useLocation } from 'react-router'; +import { RedoOutlined, UserOutlined } from '@ant-design/icons'; +import { Button, message, Modal, Select, Typography } from 'antd'; import styled from 'styled-components'; 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'; const ModalSection = styled.div` display: flex; @@ -35,6 +37,15 @@ const CreateInviteTokenButton = styled(Button)` margin-left: -6px; `; +const RoleSelect = styled(Select)` + min-width: 105px; +`; + +const RoleIcon = styled.span` + margin-right: 6px; + font-size: 12px; +`; + type Props = { visible: boolean; onClose: () => void; @@ -42,16 +53,89 @@ 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(); + + 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 onSelectRole = (roleUrn: string) => { + const roleFromMap: DataHubRole = rolesMap.get(roleUrn) as DataHubRole; + setSelectedRole(roleFromMap); + }; + + const noRoleText = 'No Role'; + + const { data: getInviteTokenData } = useGetInviteTokenQuery({ + skip: !visible, + variables: { input: { roleUrn: selectedRole?.urn } }, + }); + + const [isInviteTokenCreated, setIsInviteTokenCreated] = useState(false); + const [createInviteTokenMutation, { data: createInviteTokenData }] = useCreateInviteTokenMutation(); + + const createInviteToken = (roleUrn?: string) => { + createInviteTokenMutation({ + variables: { + input: { + roleUrn, + }, + }, + }) + .then(({ errors }) => { + if (!errors) { + setIsInviteTokenCreated(true); + } + }) + .catch((e) => { + message.destroy(); + message.error({ + content: `Failed to create Invite Token for role ${selectedRole?.name} : \n ${e.message || ''}`, + duration: 3, + }); + }); + }; - const [createNativeUserInviteToken, { data: createNativeUserInviteTokenData }] = - useCreateNativeUserInviteTokenMutation({}); + const inviteToken = useMemo(() => { + if (isInviteTokenCreated) { + return createInviteTokenData?.createInviteToken?.inviteToken; + } + return getInviteTokenData?.getInviteToken?.inviteToken || ''; + }, [getInviteTokenData, createInviteTokenData, isInviteTokenCreated]); - const inviteToken = createNativeUserInviteTokenData?.createNativeUserInviteToken?.inviteToken - ? createNativeUserInviteTokenData?.createNativeUserInviteToken.inviteToken - : getNativeUserInviteTokenData?.getNativeUserInviteToken?.inviteToken || ''; + const roleParam = `&role=${selectedRole?.urn}`; - const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}`; + const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}${ + selectedRole?.urn ? roleParam : '' + }`; return ( - Share invite link + + Select which Role you would like to set for new users joining with this invite link. + - Share this invite link with other users in your workspace! + + + {noRoleText} + + } + value={selectedRole?.urn || undefined} + onChange={(e) => onSelectRole(e as string)} + > + + {mapRoleIcon(noRoleText)} + {noRoleText} + + {roleSelectOptions()} +
{inviteLink}
@@ -79,7 +180,7 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { Generate a new invite link! Note, any old links will cease to be active. - createNativeUserInviteToken({})} size="small" type="text"> + createInviteToken(selectedRole?.urn)} size="small" type="text">
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..76a5f4b18e37a 100644 --- a/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx +++ b/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx @@ -15,6 +15,7 @@ import { EntityCapabilityType } from '../../entity/Entity'; import { useBatchAssignRoleMutation } from '../../../graphql/mutations.generated'; import { CorpUser, DataHubRole, DataHubPolicy } from '../../../types.generated'; import RoleDetailsModal from './RoleDetailsModal'; +import ViewRoleInviteTokenModal from './ViewRoleInviteTokenModal'; const SourceContainer = styled.div``; @@ -32,6 +33,10 @@ const PageContainer = styled.span` width: 100%; `; +const AddUsersButton = styled(Button)` + margin-right: 16px; +`; + const DEFAULT_PAGE_SIZE = 10; // TODO: Cleanup the styling. @@ -42,6 +47,7 @@ export const ManageRoles = () => { const paramsQuery = (params?.query as string) || undefined; const [query, setQuery] = useState(undefined); const [isBatchAddRolesModalVisible, setIsBatchAddRolesModalVisible] = useState(false); + const [isInviteToRoleModalVisible, setIsInviteToRoleModalVisible] = useState(false); const [focusRole, setFocusRole] = useState(); const [showViewRoleModal, setShowViewRoleModal] = useState(false); useEffect(() => setQuery(paramsQuery), [paramsQuery]); @@ -160,20 +166,32 @@ 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 +265,12 @@ export const ManageRoles = () => { showSizeChanger={false} /> - {showViewRoleModal && ( - - )} + + setIsInviteToRoleModalVisible(false)} + /> ); }; diff --git a/datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx b/datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx new file mode 100644 index 0000000000000..757e804061873 --- /dev/null +++ b/datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx @@ -0,0 +1,119 @@ +import React, { useMemo, useState } from 'react'; +import { RedoOutlined } from '@ant-design/icons'; +import { Button, message, Modal, Typography } from 'antd'; +import styled from 'styled-components'; +import { useGetInviteTokenQuery } from '../../../graphql/role.generated'; +import { useCreateInviteTokenMutation } from '../../../graphql/mutations.generated'; +import { DataHubRole } from '../../../types.generated'; +import { PageRoutes } from '../../../conf/Global'; + +const ModalSection = styled.div` + display: flex; + flex-direction: column; + padding-bottom: 12px; +`; + +const ModalSectionHeader = styled(Typography.Text)` + &&&& { + padding: 0px; + margin: 0px; + margin-bottom: 4px; + } +`; + +const ModalSectionParagraph = styled(Typography.Paragraph)` + &&&& { + padding: 0px; + margin: 0px; + } +`; + +const CreateInviteTokenButton = styled(Button)` + display: inline-block; + width: 20px; + margin-left: -6px; +`; + +type Props = { + role: DataHubRole; + visible: boolean; + onClose: () => void; +}; + +export default function ViewRoleInviteTokenModal({ role, visible, onClose }: Props) { + const baseUrl = window.location.origin; + const { data: getInviteTokenData } = useGetInviteTokenQuery({ + skip: !role?.urn, + variables: { input: { roleUrn: role?.urn } }, + }); + const [isInviteTokenCreated, setIsInviteTokenCreated] = useState(false); + + const [createInviteTokenMutation, { data: createInviteTokenData }] = useCreateInviteTokenMutation(); + + const createInviteToken = (roleUrn: string) => { + createInviteTokenMutation({ + variables: { + input: { + roleUrn, + }, + }, + }) + .then(({ errors }) => { + if (!errors) { + setIsInviteTokenCreated(true); + } + }) + .catch((e) => { + message.destroy(); + message.error({ + content: `Failed to create Invite Token for role ${role?.name} : \n ${e.message || ''}`, + duration: 3, + }); + }); + }; + + const inviteToken = useMemo(() => { + if (isInviteTokenCreated) { + return createInviteTokenData?.createInviteToken?.inviteToken; + } + return getInviteTokenData?.getInviteToken?.inviteToken || ''; + }, [getInviteTokenData, createInviteTokenData, isInviteTokenCreated]); + + const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}&role=${role?.urn}`; + + return ( + + Invite Users to become {role?.name}s + + } + visible={visible} + onCancel={onClose} + > + + + Share this link with other users in your workspace to assume the {role?.name} role + + + If a user does not have a DataHub account, they will be prompted to create one and then will be + assigned to the {role?.name} role. + + +
{inviteLink}
+
+
+ + Generate a new link + + Generate a new invite link! Note, any old links will cease to be active. + + createInviteToken(role.urn)}> + + + +
+ ); +} 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/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index d741a5dcfbbb4..025744eee07b0 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -236,6 +236,9 @@ public class Constants { // Invite Token public static final String INVITE_TOKEN_ASPECT_NAME = "inviteToken"; + public static final int LOWERCASE_ASCII_START = 97; + public static final int LOWERCASE_ASCII_END = 122; + public static final int INVITE_TOKEN_LENGTH = 32; // Relationships public static final String IS_MEMBER_OF_GROUP_RELATIONSHIP_NAME = "IsMemberOfGroup"; 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-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index 42ac08270db8b..9d6985ec97335 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -259,4 +259,5 @@ entities: keyAspect: dataHubRoleKey aspects: - dataHubRoleInfo + - inviteToken events: 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..8bef74e1a092b --- /dev/null +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/invite/InviteTokenService.java @@ -0,0 +1,200 @@ +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.events.metadata.ChangeType; +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.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; +import java.net.URISyntaxException; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Collections; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.metadata.Constants.*; + + +@Slf4j +public class InviteTokenService { + private static final String HASHING_ALGORITHM = "SHA-256"; + 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; + private final MessageDigest _messageDigest; + private final Base64.Encoder _encoder; + + public InviteTokenService(@Nonnull EntityClient entityClient, @Nonnull SecretService secretService) throws Exception { + _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null"); + _secretService = Objects.requireNonNull(secretService, "secretService must not be null"); + _messageDigest = MessageDigest.getInstance(HASHING_ALGORITHM); + _encoder = Base64.getEncoder(); + } + + public Urn getInviteTokenUrn(@Nonnull final String inviteTokenStr) throws URISyntaxException { + byte[] hashedInviteTokenBytes = _messageDigest.digest(inviteTokenStr.getBytes()); + String hashedInviteTokenStr = _encoder.encodeToString(hashedInviteTokenBytes); + 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); + } + + public Optional getRoleUrnFromInviteToken(@Nonnull final Urn inviteTokenUrn, + @Nonnull final Authentication authentication) throws URISyntaxException, RemoteInvocationException { + final EntityResponse inviteTokenEntity = + _entityClient.getV2(INVITE_TOKEN_ENTITY_NAME, inviteTokenUrn, Collections.singleton(INVITE_TOKEN_ASPECT_NAME), + authentication); + if (inviteTokenEntity == null) { + return Optional.empty(); + } + + 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)) { + return Optional.empty(); + } + + com.linkedin.identity.InviteToken inviteToken = + new com.linkedin.identity.InviteToken(aspectMap.get(INVITE_TOKEN_ASPECT_NAME).getValue().data()); + return inviteToken.hasRole() ? Optional.of(inviteToken.getRole()) : Optional.empty(); + } + + @Nonnull + public String getInviteToken(@Nonnull final Optional optionalRoleUrn, boolean regenerate, + @Nonnull final Authentication authentication) throws Exception { + final Filter inviteTokenFilter; + if (optionalRoleUrn.isPresent()) { + final Urn roleUrn = optionalRoleUrn.get(); + if (!_entityClient.exists(roleUrn, authentication)) { + throw new RuntimeException(String.format("Role %s does not exist", roleUrn)); + } + inviteTokenFilter = createInviteTokenFilter(roleUrn); + } else { + inviteTokenFilter = createInviteTokenFilter(); + } + + final SearchResult searchResult = + _entityClient.filter(INVITE_TOKEN_ENTITY_NAME, inviteTokenFilter, null, 0, 10, authentication); + // If there are no entities in the result, create a new invite token. + if (regenerate || searchResult.getEntities().isEmpty()) { + return createInviteToken(optionalRoleUrn, searchResult, authentication); + } + + final SearchEntity searchEntity = searchResult.getEntities().get(0); + final Urn inviteTokenUrn = searchEntity.getEntity(); + 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)) { + return createInviteToken(optionalRoleUrn, searchResult, authentication); + } + + com.linkedin.identity.InviteToken inviteToken = + new com.linkedin.identity.InviteToken(aspectMap.get(INVITE_TOKEN_ASPECT_NAME).getValue().data()); + return _secretService.decrypt(inviteToken.getToken()); + } + + 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(Urn roleUrn) { + 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(roleUrn.toString()); + roleCriterion.setCondition(Condition.EQUAL); + + andCriterion.add(roleCriterion); + conjunction.setAnd(andCriterion); + disjunction.add(conjunction); + filter.setOr(disjunction); + + return filter; + } + + @Nonnull + private String createInviteToken(@Nonnull final Optional roleUrn, @Nonnull final SearchResult searchResult, + @Nonnull final Authentication authentication) throws Exception { + deleteExistingInviteTokens(searchResult, authentication); + + String inviteTokenStr = UUID.randomUUID().toString(); + byte[] hashedInviteTokenBytes = _messageDigest.digest(inviteTokenStr.getBytes()); + String hashedInviteTokenStr = _encoder.encodeToString(hashedInviteTokenBytes); + InviteTokenKey inviteTokenKey = new InviteTokenKey(); + inviteTokenKey.setId(hashedInviteTokenStr); + com.linkedin.identity.InviteToken inviteTokenAspect = + new com.linkedin.identity.InviteToken().setToken(_secretService.encrypt(inviteTokenStr)); + roleUrn.ifPresent(inviteTokenAspect::setRole); + + // Ingest inviteToken MCP + final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal(); + inviteTokenProposal.setEntityType(INVITE_TOKEN_ENTITY_NAME); + inviteTokenProposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(inviteTokenKey)); + inviteTokenProposal.setAspectName(INVITE_TOKEN_ASPECT_NAME); + inviteTokenProposal.setAspect(GenericRecordUtils.serializeAspect(inviteTokenAspect)); + inviteTokenProposal.setChangeType(ChangeType.UPSERT); + _entityClient.ingestProposal(inviteTokenProposal, 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..f571168a19910 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 @@ -33,9 +33,6 @@ */ @Slf4j 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"; @@ -49,35 +46,22 @@ public class NativeUserService { 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; + _entityService = Objects.requireNonNull(entityService, "entityService must not be null!"); + _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null!"); + _secretService = Objects.requireNonNull(secretService, "secretService must not be null!"); _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) + @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)) { @@ -151,7 +135,7 @@ public String generateNativeUserInviteToken(Authentication authentication) throw String token = generateRandomLowercaseToken(INVITE_TOKEN_LENGTH); inviteToken.setToken(_secretService.encrypt(token)); - // Ingest corpUserCredentials MCP + // Ingest InviteToken MCP final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal(); inviteTokenProposal.setEntityType(INVITE_TOKEN_ENTITY_NAME); inviteTokenProposal.setEntityUrn(Urn.createFromString(GLOBAL_INVITE_TOKEN)); 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..c9125f492b44e --- /dev/null +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/role/RoleService.java @@ -0,0 +1,53 @@ +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.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 java.net.URISyntaxException; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.metadata.Constants.*; + + +@Slf4j +public class RoleService { + private static final String HASHING_ALGORITHM = "SHA-256"; + private final EntityClient _entityClient; + + public RoleService(@Nonnull final EntityClient entityClient) { + _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)); + + // 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/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..504696d2cc60e --- /dev/null +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java @@ -0,0 +1,213 @@ +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 java.util.Optional; +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 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 testGetRoleUrnFromInviteTokenNullEntity() throws Exception { + when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), + eq(SYSTEM_AUTHENTICATION))).thenReturn(null); + + Optional optionalRoleUrn = + _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertFalse(optionalRoleUrn.isPresent()); + } + + @Test + public void testGetRoleUrnFromInviteTokenEmptyAspectMap() 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); + + Optional optionalRoleUrn = + _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertFalse(optionalRoleUrn.isPresent()); + } + + @Test + public void testGetRoleUrnFromInviteTokenNoRole() 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); + + Optional optionalRoleUrn = + _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertFalse(optionalRoleUrn.isPresent()); + } + + @Test + public void testGetRoleUrnFromInviteToken() 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); + + Optional optionalRoleUrn = + _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertTrue(optionalRoleUrn.isPresent()); + assertEquals(optionalRoleUrn.get(), roleUrn); + } + + @Test + public void getInviteTokenRoleUrnDoesNotExist() throws Exception { + when(_entityClient.exists(eq(roleUrn), eq(SYSTEM_AUTHENTICATION))).thenReturn(false); + + assertThrows(() -> _inviteTokenService.getInviteToken(Optional.of(roleUrn), 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.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); + + _inviteTokenService.getInviteToken(Optional.empty(), 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.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); + + _inviteTokenService.getInviteToken(Optional.empty(), 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(Optional.empty(), 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); + + _inviteTokenService.getInviteToken(Optional.empty(), false, SYSTEM_AUTHENTICATION); + verify(_entityClient, times(1)).ingestProposal(any(), eq(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(Optional.empty(), 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..2f32c25538fbb 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 @@ -64,59 +64,32 @@ public void testConstructor() throws Exception { @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.encrypt(any())).thenReturn(ENCRYPTED_SALT); - _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 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..39caff7a188be --- /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.entity.client.JavaEntityClient; +import com.linkedin.gms.factory.spring.YamlPropertySourceFactory; +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..3842062beaad3 --- /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.entity.client.JavaEntityClient; +import com.linkedin.gms.factory.spring.YamlPropertySourceFactory; +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..36b1726d00535 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); - GenericAspect aspect = - GenericRecordUtils.serializeAspect(EntityKeyUtils.convertUrnToEntityKey(urn, keyAspectSpec)); + final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(roleUrn); + GenericAspect aspect = GenericRecordUtils.serializeAspect( + EntityKeyUtils.convertUrnToEntityKey(roleUrn, keyAspectSpec.getPegasusSchema())); 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); } } From 3f6f9ecfaa07d2c9b4f1f8833307df426ea117bb Mon Sep 17 00:00:00 2001 From: aditya-radhakrishnan Date: Mon, 19 Sep 2022 10:04:37 -0700 Subject: [PATCH 2/6] Address review comments --- .../datahub/graphql/GmsGraphQLEngine.java | 5 +- .../resolvers/role/AcceptRoleResolver.java | 9 +- .../role/CreateInviteTokenResolver.java | 8 +- .../role/GetInviteTokenResolver.java | 8 +- .../CreateNativeUserInviteTokenResolver.java | 28 +++-- .../GetNativeUserInviteTokenResolver.java | 28 ++--- .../src/main/resources/entity.graphql | 4 +- .../role/AcceptRoleResolverTest.java | 7 +- ...eateNativeUserInviteTokenResolverTest.java | 12 +- .../GetNativeUserInviteTokenResolverTest.java | 12 +- datahub-web-react/src/app/auth/SignUp.tsx | 63 +++++----- .../identity/user/ViewInviteTokenModal.tsx | 78 ++++++------ .../src/app/permissions/roles/ManageRoles.tsx | 17 --- .../roles/ViewRoleInviteTokenModal.tsx | 119 ------------------ .../java/com/linkedin/metadata/Constants.java | 3 - .../src/main/resources/entity-registry.yml | 1 - .../invite/InviteTokenService.java | 94 +++++++------- .../user/NativeUserService.java | 52 ++------ .../authorization/role/RoleService.java | 7 +- .../invite/InviteTokenServiceTest.java | 43 +++---- .../user/NativeUserServiceTest.java | 34 +---- 21 files changed, 204 insertions(+), 428 deletions(-) delete mode 100644 datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx 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 ddec29750a493..a97f8292d47a6 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 @@ -674,7 +674,7 @@ 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("getNativeUserInviteToken", new GetNativeUserInviteTokenResolver(this.inviteTokenService)) .dataFetcher("entity", getEntityResolver()) .dataFetcher("entities", getEntitiesResolver()) .dataFetcher("listRoles", new ListRolesResolver(this.entityClient)) @@ -794,7 +794,8 @@ 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("createNativeUserInviteToken", + new CreateNativeUserInviteTokenResolver(this.inviteTokenService)) .dataFetcher("createNativeUserResetToken", new CreateNativeUserResetTokenResolver(this.nativeUserService)) .dataFetcher("batchUpdateSoftDeleted", new BatchUpdateSoftDeletedResolver(this.entityService)) .dataFetcher("updateUserSetting", new UpdateUserSettingResolver(this.entityService)) 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 index 192cbbb7d5bab..3f013f05e73a7 100644 --- 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 @@ -8,7 +8,6 @@ import com.linkedin.datahub.graphql.generated.AcceptRoleInput; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,14 +37,14 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw throw new RuntimeException(String.format("Invite token %s is invalid", inviteTokenStr)); } - Optional roleUrnOptional = _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, authentication); - if (roleUrnOptional.isPresent()) { - _roleService.assignRoleToActor(authentication.getActor().toUrnStr(), roleUrnOptional.get(), authentication); + 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 perform update against input %s", input), 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/CreateInviteTokenResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/role/CreateInviteTokenResolver.java index 514d07f1aa24e..6bdf52e2f89f1 100644 --- 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 @@ -2,14 +2,12 @@ import com.datahub.authentication.Authentication; import com.datahub.authentication.invite.InviteTokenService; -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.CreateInviteTokenInput; import com.linkedin.datahub.graphql.generated.InviteToken; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,11 +35,9 @@ public CompletableFuture get(final DataFetchingEnvironment environm return CompletableFuture.supplyAsync(() -> { try { - Optional optionalRoleUrn = - roleUrnStr == null ? Optional.empty() : Optional.of(Urn.createFromString(roleUrnStr)); - return new InviteToken(_inviteTokenService.getInviteToken(optionalRoleUrn, true, authentication)); + return new InviteToken(_inviteTokenService.getInviteToken(roleUrnStr, true, authentication)); } catch (Exception e) { - throw new RuntimeException(String.format("Failed to perform update against input %s", input), 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 index caf85dc12c05b..0b0cbbb7ba473 100644 --- 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 @@ -2,14 +2,12 @@ import com.datahub.authentication.Authentication; import com.datahub.authentication.invite.InviteTokenService; -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.GetInviteTokenInput; import com.linkedin.datahub.graphql.generated.InviteToken; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,11 +35,9 @@ public CompletableFuture get(final DataFetchingEnvironment environm return CompletableFuture.supplyAsync(() -> { try { - Optional optionalRoleUrn = - roleUrnStr == null ? Optional.empty() : Optional.of(Urn.createFromString(roleUrnStr)); - return new InviteToken(_inviteTokenService.getInviteToken(optionalRoleUrn, false, authentication)); + return new InviteToken(_inviteTokenService.getInviteToken(roleUrnStr, false, authentication)); } catch (Exception e) { - throw new RuntimeException(String.format("Failed to perform update against input %s", input), 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/user/CreateNativeUserInviteTokenResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolver.java index 9f8a5ef0e87e4..59f665946d80a 100644 --- 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 @@ -1,12 +1,14 @@ package com.linkedin.datahub.graphql.resolvers.user; -import com.datahub.authentication.user.NativeUserService; +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.InviteToken; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; @@ -14,27 +16,27 @@ * Resolver responsible for creating an invite token that Admins can share with prospective users to create native * user accounts. */ +@RequiredArgsConstructor +@Deprecated public class CreateNativeUserInviteTokenResolver implements DataFetcher> { - private final NativeUserService _nativeUserService; - - public CreateNativeUserInviteTokenResolver(final NativeUserService nativeUserService) { - _nativeUserService = nativeUserService; - } + 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 Authentication authentication = context.getAuthentication(); + 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); + return new InviteToken(_inviteTokenService.getInviteToken(null, true, authentication)); } catch (Exception e) { - throw new RuntimeException("Failed to generate new invite token"); + throw new RuntimeException("Failed to create 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 index 1d1a329c790b3..c3517b61fcedf 100644 --- 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 @@ -1,12 +1,14 @@ package com.linkedin.datahub.graphql.resolvers.user; -import com.datahub.authentication.user.NativeUserService; +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.InviteToken; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; @@ -14,28 +16,26 @@ * 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. */ +@RequiredArgsConstructor +@Deprecated public class GetNativeUserInviteTokenResolver implements DataFetcher> { - private final NativeUserService _nativeUserService; - - public GetNativeUserInviteTokenResolver(final NativeUserService nativeUserService) { - _nativeUserService = nativeUserService; - } + 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."); + } - return CompletableFuture.supplyAsync(() -> { - if (!canManageUserCredentials(context)) { - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); - } + final Authentication authentication = context.getAuthentication(); + return CompletableFuture.supplyAsync(() -> { try { - String inviteToken = _nativeUserService.getNativeUserInviteToken(context.getAuthentication()); - return new InviteToken(inviteToken); + return new InviteToken(_inviteTokenService.getInviteToken(null, false, authentication)); } catch (Exception e) { - throw new RuntimeException("Failed to generate new invite token"); + throw new RuntimeException("Failed to get invite token"); } }); } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 0bedd48a85a11..c5899d0d80a3e 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -167,7 +167,7 @@ type Query { """ Gets the current invite token. If the optional regenerate param is set to true, generate a new invite token. """ - getNativeUserInviteToken: InviteToken + getNativeUserInviteToken: InviteToken @deprecated(reason: "Use getInviteToken instead") """ Gets an entity based on its urn @@ -497,7 +497,7 @@ type Mutation { """ Generates an invite token that can be shared with prospective users to create their accounts. """ - createNativeUserInviteToken: InviteToken + createNativeUserInviteToken: InviteToken @deprecated(reason: "Use createInviteToken instead") """ Generates a token that can be shared with existing native users to reset their credentials. 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 index 9aa8ee741b2f5..8d9a288bc6aa6 100644 --- 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 @@ -8,7 +8,6 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.AcceptRoleInput; import graphql.schema.DataFetchingEnvironment; -import java.util.Optional; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -71,8 +70,7 @@ public void testNoRoleUrn() throws Exception { 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.getRoleUrnFromInviteToken(eq(inviteTokenUrn), eq(_authentication))).thenReturn( - Optional.empty()); + when(_inviteTokenService.getInviteTokenRole(eq(inviteTokenUrn), eq(_authentication))).thenReturn(null); AcceptRoleInput input = new AcceptRoleInput(); input.setInviteToken(INVITE_TOKEN_STRING); @@ -89,8 +87,7 @@ public void testAssignRolePasses() throws Exception { 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.getRoleUrnFromInviteToken(eq(inviteTokenUrn), eq(_authentication))).thenReturn( - Optional.of(roleUrn)); + 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); 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/user/CreateNativeUserInviteTokenResolverTest.java index cf66367272fe5..e71bc0fcd9109 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/user/CreateNativeUserInviteTokenResolverTest.java @@ -1,7 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.user; import com.datahub.authentication.Authentication; -import com.datahub.authentication.user.NativeUserService; +import com.datahub.authentication.invite.InviteTokenService; import com.linkedin.datahub.graphql.QueryContext; import graphql.schema.DataFetchingEnvironment; import org.testng.annotations.BeforeMethod; @@ -16,22 +16,22 @@ public class CreateNativeUserInviteTokenResolverTest { private static final String INVITE_TOKEN = "inviteToken"; - private NativeUserService _nativeUserService; + private InviteTokenService _inviteTokenService; private CreateNativeUserInviteTokenResolver _resolver; private DataFetchingEnvironment _dataFetchingEnvironment; private Authentication _authentication; @BeforeMethod public void setupTest() { - _nativeUserService = mock(NativeUserService.class); + _inviteTokenService = mock(InviteTokenService.class); _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); _authentication = mock(Authentication.class); - _resolver = new CreateNativeUserInviteTokenResolver(_nativeUserService); + _resolver = new CreateNativeUserInviteTokenResolver(_inviteTokenService); } @Test - public void testFailsCannotManageUserCredentials() { + public void testFailsDenyContext() { QueryContext mockContext = getMockDenyContext(); when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); @@ -43,7 +43,7 @@ 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(true), eq(_authentication))).thenReturn(INVITE_TOKEN); assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken()); } 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 index 0870bcae5661b..80cc0436f4904 100644 --- 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 @@ -1,7 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.user; import com.datahub.authentication.Authentication; -import com.datahub.authentication.user.NativeUserService; +import com.datahub.authentication.invite.InviteTokenService; import com.linkedin.datahub.graphql.QueryContext; import graphql.schema.DataFetchingEnvironment; import org.testng.annotations.BeforeMethod; @@ -16,22 +16,22 @@ public class GetNativeUserInviteTokenResolverTest { private static final String INVITE_TOKEN = "inviteToken"; - private NativeUserService _nativeUserService; + private InviteTokenService _inviteTokenService; private GetNativeUserInviteTokenResolver _resolver; private DataFetchingEnvironment _dataFetchingEnvironment; private Authentication _authentication; @BeforeMethod public void setupTest() { - _nativeUserService = mock(NativeUserService.class); + _inviteTokenService = mock(InviteTokenService.class); _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); _authentication = mock(Authentication.class); - _resolver = new GetNativeUserInviteTokenResolver(_nativeUserService); + _resolver = new GetNativeUserInviteTokenResolver(_inviteTokenService); } @Test - public void testFailsCannotManageUserCredentials() { + public void testFailsDenyContext() { QueryContext mockContext = getMockDenyContext(); when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); @@ -43,7 +43,7 @@ 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); + when(_inviteTokenService.getInviteToken(any(), eq(false), eq(_authentication))).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 6330c70c868ad..386399427d697 100644 --- a/datahub-web-react/src/app/auth/SignUp.tsx +++ b/datahub-web-react/src/app/auth/SignUp.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Input, Button, Form, message, Image, Select } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { useReactiveVar } from '@apollo/client'; @@ -65,6 +65,32 @@ export const SignUp: React.VFC = () => { const { refreshContext } = useAppConfig(); + const [acceptRoleMutation] = useAcceptRoleMutation(); + const acceptRole = useCallback(() => { + 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, + }); + }); + }, [acceptRoleMutation, inviteToken]); + const handleSignUp = useCallback( (values: FormValues) => { setLoading(true); @@ -88,6 +114,7 @@ export const SignUp: React.VFC = () => { } isLoggedInVar(true); refreshContext(); + acceptRole(); analytics.event({ type: EventType.SignUpEvent, title: values.title }); return Promise.resolve(); }) @@ -96,41 +123,9 @@ export const SignUp: React.VFC = () => { }) .finally(() => setLoading(false)); }, - [refreshContext, inviteToken], + [refreshContext, inviteToken, acceptRole], ); - 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, - }); - }); - }; - - useEffect(() => { - if (isLoggedIn) { - acceptRole(); - } - }); - if (isLoggedIn && !loading) { return ; } diff --git a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx index 3fa42e188231b..fd15e24fa3eb4 100644 --- a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx +++ b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import * as QueryString from 'query-string'; import { useLocation } from 'react-router'; import { RedoOutlined, UserOutlined } from '@ant-design/icons'; -import { Button, message, Modal, Select, Typography } from 'antd'; +import { Button, Col, message, Modal, Select, Typography } from 'antd'; import styled from 'styled-components'; import { PageRoutes } from '../../../conf/Global'; import { useGetInviteTokenQuery, useListRolesQuery } from '../../../graphql/role.generated'; @@ -31,6 +31,12 @@ const ModalSectionParagraph = styled(Typography.Paragraph)` } `; +const InviteLinkParagraph = styled(Typography.Paragraph)` + display: flex; + flex-direction: row; + justify-content: flex-start; +`; + const CreateInviteTokenButton = styled(Button)` display: inline-block; width: 20px; @@ -38,6 +44,7 @@ const CreateInviteTokenButton = styled(Button)` `; const RoleSelect = styled(Select)` + margin-top: 11px; min-width: 105px; `; @@ -59,6 +66,11 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { const [query, setQuery] = useState(undefined); useEffect(() => setQuery(paramsQuery), [paramsQuery]); const [selectedRole, setSelectedRole] = useState(); + const [isInviteTokenCreated, setIsInviteTokenCreated] = useState(false); + const [createInviteTokenMutation, { data: createInviteTokenData }] = useCreateInviteTokenMutation(); + + // Code related to listing role options and selecting a role + const noRoleText = 'No Role'; const { data: rolesData } = useListRolesQuery({ fetchPolicy: 'no-cache', @@ -92,16 +104,12 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { setSelectedRole(roleFromMap); }; - const noRoleText = 'No Role'; - + // Code related to creating an invite token const { data: getInviteTokenData } = useGetInviteTokenQuery({ skip: !visible, variables: { input: { roleUrn: selectedRole?.urn } }, }); - const [isInviteTokenCreated, setIsInviteTokenCreated] = useState(false); - const [createInviteTokenMutation, { data: createInviteTokenData }] = useCreateInviteTokenMutation(); - const createInviteToken = (roleUrn?: string) => { createInviteTokenMutation({ variables: { @@ -131,49 +139,47 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { return getInviteTokenData?.getInviteToken?.inviteToken || ''; }, [getInviteTokenData, createInviteTokenData, isInviteTokenCreated]); - const roleParam = `&role=${selectedRole?.urn}`; - - const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}${ - selectedRole?.urn ? roleParam : '' - }`; + const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}`; return ( - Invite new DataHub users + Invite Users } visible={visible} onCancel={onClose} > - - Select which Role you would like to set for new users joining with this invite link. - - - - + Role for users joining with this invite link + + + + + {noRoleText} + + } + value={selectedRole?.urn || undefined} + onChange={(e) => onSelectRole(e as string)} + > + + {mapRoleIcon(noRoleText)} {noRoleText} - - } - value={selectedRole?.urn || undefined} - onChange={(e) => onSelectRole(e as string)} - > - - {mapRoleIcon(noRoleText)} - {noRoleText} - - {roleSelectOptions()} - - - -
{inviteLink}
-
+ + {roleSelectOptions()} + + + + +
{inviteLink}
+
+ +
Generate a new link diff --git a/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx b/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx index 76a5f4b18e37a..e5220296f9ece 100644 --- a/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx +++ b/datahub-web-react/src/app/permissions/roles/ManageRoles.tsx @@ -15,7 +15,6 @@ import { EntityCapabilityType } from '../../entity/Entity'; import { useBatchAssignRoleMutation } from '../../../graphql/mutations.generated'; import { CorpUser, DataHubRole, DataHubPolicy } from '../../../types.generated'; import RoleDetailsModal from './RoleDetailsModal'; -import ViewRoleInviteTokenModal from './ViewRoleInviteTokenModal'; const SourceContainer = styled.div``; @@ -47,7 +46,6 @@ export const ManageRoles = () => { const paramsQuery = (params?.query as string) || undefined; const [query, setQuery] = useState(undefined); const [isBatchAddRolesModalVisible, setIsBatchAddRolesModalVisible] = useState(false); - const [isInviteToRoleModalVisible, setIsInviteToRoleModalVisible] = useState(false); const [focusRole, setFocusRole] = useState(); const [showViewRoleModal, setShowViewRoleModal] = useState(false); useEffect(() => setQuery(paramsQuery), [paramsQuery]); @@ -181,16 +179,6 @@ export const ManageRoles = () => { ADD USERS - - - ); }, @@ -266,11 +254,6 @@ export const ManageRoles = () => { /> - setIsInviteToRoleModalVisible(false)} - /> ); }; diff --git a/datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx b/datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx deleted file mode 100644 index 757e804061873..0000000000000 --- a/datahub-web-react/src/app/permissions/roles/ViewRoleInviteTokenModal.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { RedoOutlined } from '@ant-design/icons'; -import { Button, message, Modal, Typography } from 'antd'; -import styled from 'styled-components'; -import { useGetInviteTokenQuery } from '../../../graphql/role.generated'; -import { useCreateInviteTokenMutation } from '../../../graphql/mutations.generated'; -import { DataHubRole } from '../../../types.generated'; -import { PageRoutes } from '../../../conf/Global'; - -const ModalSection = styled.div` - display: flex; - flex-direction: column; - padding-bottom: 12px; -`; - -const ModalSectionHeader = styled(Typography.Text)` - &&&& { - padding: 0px; - margin: 0px; - margin-bottom: 4px; - } -`; - -const ModalSectionParagraph = styled(Typography.Paragraph)` - &&&& { - padding: 0px; - margin: 0px; - } -`; - -const CreateInviteTokenButton = styled(Button)` - display: inline-block; - width: 20px; - margin-left: -6px; -`; - -type Props = { - role: DataHubRole; - visible: boolean; - onClose: () => void; -}; - -export default function ViewRoleInviteTokenModal({ role, visible, onClose }: Props) { - const baseUrl = window.location.origin; - const { data: getInviteTokenData } = useGetInviteTokenQuery({ - skip: !role?.urn, - variables: { input: { roleUrn: role?.urn } }, - }); - const [isInviteTokenCreated, setIsInviteTokenCreated] = useState(false); - - const [createInviteTokenMutation, { data: createInviteTokenData }] = useCreateInviteTokenMutation(); - - const createInviteToken = (roleUrn: string) => { - createInviteTokenMutation({ - variables: { - input: { - roleUrn, - }, - }, - }) - .then(({ errors }) => { - if (!errors) { - setIsInviteTokenCreated(true); - } - }) - .catch((e) => { - message.destroy(); - message.error({ - content: `Failed to create Invite Token for role ${role?.name} : \n ${e.message || ''}`, - duration: 3, - }); - }); - }; - - const inviteToken = useMemo(() => { - if (isInviteTokenCreated) { - return createInviteTokenData?.createInviteToken?.inviteToken; - } - return getInviteTokenData?.getInviteToken?.inviteToken || ''; - }, [getInviteTokenData, createInviteTokenData, isInviteTokenCreated]); - - const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}&role=${role?.urn}`; - - return ( - - Invite Users to become {role?.name}s - - } - visible={visible} - onCancel={onClose} - > - - - Share this link with other users in your workspace to assume the {role?.name} role - - - If a user does not have a DataHub account, they will be prompted to create one and then will be - assigned to the {role?.name} role. - - -
{inviteLink}
-
-
- - Generate a new link - - Generate a new invite link! Note, any old links will cease to be active. - - createInviteToken(role.urn)}> - - - -
- ); -} 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 025744eee07b0..d741a5dcfbbb4 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -236,9 +236,6 @@ public class Constants { // Invite Token public static final String INVITE_TOKEN_ASPECT_NAME = "inviteToken"; - public static final int LOWERCASE_ASCII_START = 97; - public static final int LOWERCASE_ASCII_END = 122; - public static final int INVITE_TOKEN_LENGTH = 32; // Relationships public static final String IS_MEMBER_OF_GROUP_RELATIONSHIP_NAME = "IsMemberOfGroup"; diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index 9d6985ec97335..42ac08270db8b 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -259,5 +259,4 @@ entities: keyAspect: dataHubRoleKey aspects: - dataHubRoleInfo - - inviteToken events: 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 index 8bef74e1a092b..0417fc76d8635 100644 --- 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 @@ -24,9 +24,9 @@ import java.util.Base64; import java.util.Collections; import java.util.Objects; -import java.util.Optional; import java.util.UUID; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import static com.linkedin.metadata.Constants.*; @@ -50,8 +50,7 @@ public InviteTokenService(@Nonnull EntityClient entityClient, @Nonnull SecretSer } public Urn getInviteTokenUrn(@Nonnull final String inviteTokenStr) throws URISyntaxException { - byte[] hashedInviteTokenBytes = _messageDigest.digest(inviteTokenStr.getBytes()); - String hashedInviteTokenStr = _encoder.encodeToString(hashedInviteTokenBytes); + String hashedInviteTokenStr = hashString(inviteTokenStr); String inviteTokenUrnStr = String.format("urn:li:inviteToken:%s", hashedInviteTokenStr); return Urn.createFromString(inviteTokenUrnStr); } @@ -61,49 +60,48 @@ public boolean isInviteTokenValid(@Nonnull final Urn inviteTokenUrn, @Nonnull fi return _entityClient.exists(inviteTokenUrn, authentication); } - public Optional getRoleUrnFromInviteToken(@Nonnull final Urn inviteTokenUrn, - @Nonnull final Authentication authentication) throws URISyntaxException, RemoteInvocationException { - final EntityResponse inviteTokenEntity = - _entityClient.getV2(INVITE_TOKEN_ENTITY_NAME, inviteTokenUrn, Collections.singleton(INVITE_TOKEN_ASPECT_NAME), - authentication); - if (inviteTokenEntity == null) { - return Optional.empty(); - } - - 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)) { - return Optional.empty(); - } - - com.linkedin.identity.InviteToken inviteToken = - new com.linkedin.identity.InviteToken(aspectMap.get(INVITE_TOKEN_ASPECT_NAME).getValue().data()); - return inviteToken.hasRole() ? Optional.of(inviteToken.getRole()) : Optional.empty(); + @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(@Nonnull final Optional optionalRoleUrn, boolean regenerate, + public String getInviteToken(@Nullable final String roleUrnStr, boolean regenerate, @Nonnull final Authentication authentication) throws Exception { - final Filter inviteTokenFilter; - if (optionalRoleUrn.isPresent()) { - final Urn roleUrn = optionalRoleUrn.get(); - if (!_entityClient.exists(roleUrn, authentication)) { - throw new RuntimeException(String.format("Role %s does not exist", roleUrn)); - } - inviteTokenFilter = createInviteTokenFilter(roleUrn); - } else { - inviteTokenFilter = createInviteTokenFilter(); - } + final Filter inviteTokenFilter = + roleUrnStr == null ? createInviteTokenFilter() : createInviteTokenFilter(roleUrnStr); final SearchResult searchResult = _entityClient.filter(INVITE_TOKEN_ENTITY_NAME, inviteTokenFilter, null, 0, 10, authentication); - // If there are no entities in the result, create a new invite token. - if (regenerate || searchResult.getEntities().isEmpty()) { - return createInviteToken(optionalRoleUrn, searchResult, 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 String hashString(@Nonnull final String str) { + byte[] hashedBytes = _messageDigest.digest(str.getBytes()); + return _encoder.encodeToString(hashedBytes); + } + + 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); @@ -115,12 +113,10 @@ public String getInviteToken(@Nonnull final Optional optionalRoleUrn, boole 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)) { - return createInviteToken(optionalRoleUrn, searchResult, authentication); + throw new RuntimeException( + String.format("Invite token %s does not contain aspect %s", inviteTokenUrn, INVITE_TOKEN_ASPECT_NAME)); } - - com.linkedin.identity.InviteToken inviteToken = - new com.linkedin.identity.InviteToken(aspectMap.get(INVITE_TOKEN_ASPECT_NAME).getValue().data()); - return _secretService.decrypt(inviteToken.getToken()); + return new com.linkedin.identity.InviteToken(aspectMap.get(INVITE_TOKEN_ASPECT_NAME).getValue().data()); } private Filter createInviteTokenFilter() { @@ -142,7 +138,7 @@ private Filter createInviteTokenFilter() { return filter; } - private Filter createInviteTokenFilter(Urn roleUrn) { + private Filter createInviteTokenFilter(@Nonnull final String roleUrnStr) { final Filter filter = new Filter(); final ConjunctiveCriterionArray disjunction = new ConjunctiveCriterionArray(); final ConjunctiveCriterion conjunction = new ConjunctiveCriterion(); @@ -150,7 +146,7 @@ private Filter createInviteTokenFilter(Urn roleUrn) { final Criterion roleCriterion = new Criterion(); roleCriterion.setField(ROLE_FIELD_NAME); - roleCriterion.setValue(roleUrn.toString()); + roleCriterion.setValue(roleUrnStr); roleCriterion.setCondition(Condition.EQUAL); andCriterion.add(roleCriterion); @@ -162,18 +158,18 @@ private Filter createInviteTokenFilter(Urn roleUrn) { } @Nonnull - private String createInviteToken(@Nonnull final Optional roleUrn, @Nonnull final SearchResult searchResult, - @Nonnull final Authentication authentication) throws Exception { - deleteExistingInviteTokens(searchResult, authentication); - + private String createInviteToken(@Nullable final String roleUrnStr, @Nonnull final Authentication authentication) + throws Exception { String inviteTokenStr = UUID.randomUUID().toString(); - byte[] hashedInviteTokenBytes = _messageDigest.digest(inviteTokenStr.getBytes()); - String hashedInviteTokenStr = _encoder.encodeToString(hashedInviteTokenBytes); + String hashedInviteTokenStr = hashString(inviteTokenStr); InviteTokenKey inviteTokenKey = new InviteTokenKey(); inviteTokenKey.setId(hashedInviteTokenStr); com.linkedin.identity.InviteToken inviteTokenAspect = new com.linkedin.identity.InviteToken().setToken(_secretService.encrypt(inviteTokenStr)); - roleUrn.ifPresent(inviteTokenAspect::setRole); + if (roleUrnStr != null) { + Urn roleUrn = Urn.createFromString(roleUrnStr); + inviteTokenAspect.setRole(roleUrn); + } // Ingest inviteToken MCP final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal(); 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 f571168a19910..133dea5050ac1 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,7 +8,6 @@ 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; @@ -33,6 +32,8 @@ */ @Slf4j public class NativeUserService { + public static final int LOWERCASE_ASCII_START = 97; + public static final int LOWERCASE_ASCII_END = 122; 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"; @@ -44,8 +45,8 @@ public class NativeUserService { private final SecureRandom _secureRandom; private final MessageDigest _messageDigest; - public NativeUserService(@Nonnull EntityService entityService, @Nonnull EntityClient entityClient, @Nonnull SecretService secretService) - throws Exception { + public NativeUserService(@Nonnull EntityService entityService, @Nonnull EntityClient entityClient, + @Nonnull SecretService secretService) throws Exception { _entityService = Objects.requireNonNull(entityService, "entityService must not be null!"); _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null!"); _secretService = Objects.requireNonNull(secretService, "secretService must not be null!"); @@ -54,8 +55,7 @@ public NativeUserService(@Nonnull EntityService entityService, @Nonnull EntityCl } public void createNativeUser(@Nonnull String userUrnString, @Nonnull String fullName, @Nonnull String email, - @Nonnull String title, @Nonnull String password, @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!"); @@ -109,8 +109,8 @@ 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); @@ -129,35 +129,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 InviteToken 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); @@ -201,14 +174,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!"); } @@ -240,6 +211,7 @@ byte[] getRandomBytes(int length) { return randomBytes; } + // TODO: Refactor to use UUID.randomUUID().toString(); String generateRandomLowercaseToken(int length) { return _secureRandom.ints(length, LOWERCASE_ASCII_START, LOWERCASE_ASCII_END + 1) .mapToObj(i -> String.valueOf((char) i)) 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 index c9125f492b44e..278eb4e2a3e8c 100644 --- 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 @@ -11,20 +11,17 @@ 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.*; @Slf4j +@RequiredArgsConstructor public class RoleService { - private static final String HASHING_ALGORITHM = "SHA-256"; private final EntityClient _entityClient; - public RoleService(@Nonnull final EntityClient entityClient) { - _entityClient = entityClient; - } - public boolean exists(@Nonnull final Urn urn, final Authentication authentication) throws RemoteInvocationException { return _entityClient.exists(urn, 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 index 504696d2cc60e..f8de2b406c6e4 100644 --- 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 @@ -14,7 +14,6 @@ import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.secret.SecretService; -import java.util.Optional; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -67,29 +66,25 @@ public void testIsInviteTokenValidTrue() throws Exception { } @Test - public void testGetRoleUrnFromInviteTokenNullEntity() throws Exception { + public void testGetInviteTokenRoleNullEntity() throws Exception { when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), eq(SYSTEM_AUTHENTICATION))).thenReturn(null); - Optional optionalRoleUrn = - _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); - assertFalse(optionalRoleUrn.isPresent()); + assertThrows(() -> _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION)); } @Test - public void testGetRoleUrnFromInviteTokenEmptyAspectMap() throws Exception { + 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); - Optional optionalRoleUrn = - _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); - assertFalse(optionalRoleUrn.isPresent()); + assertThrows(() -> _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION)); } @Test - public void testGetRoleUrnFromInviteTokenNoRole() throws Exception { + 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); @@ -99,13 +94,12 @@ public void testGetRoleUrnFromInviteTokenNoRole() throws Exception { when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); - Optional optionalRoleUrn = - _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); - assertFalse(optionalRoleUrn.isPresent()); + Urn roleUrn = _inviteTokenService.getInviteTokenRole(inviteTokenUrn, SYSTEM_AUTHENTICATION); + assertNull(roleUrn); } @Test - public void testGetRoleUrnFromInviteToken() throws Exception { + 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); @@ -115,17 +109,16 @@ public void testGetRoleUrnFromInviteToken() throws Exception { when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), eq(SYSTEM_AUTHENTICATION))).thenReturn(entityResponse); - Optional optionalRoleUrn = - _inviteTokenService.getRoleUrnFromInviteToken(inviteTokenUrn, SYSTEM_AUTHENTICATION); - assertTrue(optionalRoleUrn.isPresent()); - assertEquals(optionalRoleUrn.get(), roleUrn); + 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(Optional.of(roleUrn), false, SYSTEM_AUTHENTICATION)); + assertThrows(() -> _inviteTokenService.getInviteToken(roleUrn.toString(), false, SYSTEM_AUTHENTICATION)); } @Test @@ -136,7 +129,7 @@ public void getInviteTokenRegenerate() throws Exception { eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); when(_secretService.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); - _inviteTokenService.getInviteToken(Optional.empty(), true, SYSTEM_AUTHENTICATION); + _inviteTokenService.getInviteToken(null, true, SYSTEM_AUTHENTICATION); verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); } @@ -148,7 +141,7 @@ public void getInviteTokenEmptySearchResult() throws Exception { eq(SYSTEM_AUTHENTICATION))).thenReturn(searchResult); when(_secretService.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); - _inviteTokenService.getInviteToken(Optional.empty(), false, SYSTEM_AUTHENTICATION); + _inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION); verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); } @@ -164,7 +157,7 @@ public void getInviteTokenNullEntity() throws Exception { when(_entityClient.getV2(eq(INVITE_TOKEN_ENTITY_NAME), eq(inviteTokenUrn), any(), eq(SYSTEM_AUTHENTICATION))).thenReturn(null); - assertThrows(() -> _inviteTokenService.getInviteToken(Optional.empty(), false, SYSTEM_AUTHENTICATION)); + assertThrows(() -> _inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION)); } @Test @@ -183,8 +176,7 @@ public void getInviteTokenNoInviteTokenAspect() throws Exception { when(_secretService.encrypt(anyString())).thenReturn(ENCRYPTED_INVITE_TOKEN_STRING); - _inviteTokenService.getInviteToken(Optional.empty(), false, SYSTEM_AUTHENTICATION); - verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); + assertThrows(() -> _inviteTokenService.getInviteToken(null, false, SYSTEM_AUTHENTICATION)); } @Test @@ -207,7 +199,6 @@ public void getInviteToken() throws Exception { when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN_STRING))).thenReturn(INVITE_TOKEN_STRING); - assertEquals(_inviteTokenService.getInviteToken(Optional.empty(), false, SYSTEM_AUTHENTICATION), - 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 2f32c25538fbb..2bf3c6d445452 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,8 +27,7 @@ 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 ENCRYPTED_INVITE_TOKEN = "encryptedInviteroToken"; private static final String RESET_TOKEN = "inviteToken"; private static final String ENCRYPTED_RESET_TOKEN = "encryptedInviteToken"; private static final String ENCRYPTED_SALT = "encryptedSalt"; @@ -112,36 +110,6 @@ public void testUpdateCorpUserCredentialsPasses() throws Exception { 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)); From b04fc7206f43dd8e9e14f9cd99d45eacea32fcb1 Mon Sep 17 00:00:00 2001 From: aditya-radhakrishnan Date: Mon, 19 Sep 2022 19:39:32 -0700 Subject: [PATCH 3/6] Some refactoring --- .../datahub/graphql/GmsGraphQLEngine.java | 5 - .../CreateNativeUserInviteTokenResolver.java | 43 ----- .../GetNativeUserInviteTokenResolver.java | 42 ----- .../src/main/resources/entity.graphql | 10 -- ...eateNativeUserInviteTokenResolverTest.java | 50 ------ .../GetNativeUserInviteTokenResolverTest.java | 50 ------ datahub-web-react/src/app/auth/SignUp.tsx | 26 +-- .../identity/user/ViewInviteTokenModal.tsx | 163 ++++++++++-------- datahub-web-react/src/graphql/user.graphql | 12 -- .../linkedin/metadata/entity/AspectUtils.java | 20 ++- .../invite/InviteTokenService.java | 16 +- .../user/NativeUserService.java | 14 +- .../authorization/role/RoleService.java | 13 +- .../auth/InviteTokenServiceFactory.java | 2 +- .../gms/factory/auth/RoleServiceFactory.java | 2 +- 15 files changed, 138 insertions(+), 330 deletions(-) delete mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolver.java delete mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolver.java delete mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolverTest.java delete mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/GetNativeUserInviteTokenResolverTest.java 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 a97f8292d47a6..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 @@ -202,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; @@ -674,7 +672,6 @@ 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.inviteTokenService)) .dataFetcher("entity", getEntityResolver()) .dataFetcher("entities", getEntitiesResolver()) .dataFetcher("listRoles", new ListRolesResolver(this.entityClient)) @@ -794,8 +791,6 @@ 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.inviteTokenService)) .dataFetcher("createNativeUserResetToken", new CreateNativeUserResetTokenResolver(this.nativeUserService)) .dataFetcher("batchUpdateSoftDeleted", new BatchUpdateSoftDeletedResolver(this.entityService)) .dataFetcher("updateUserSetting", new UpdateUserSettingResolver(this.entityService)) 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 59f665946d80a..0000000000000 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolver.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.linkedin.datahub.graphql.resolvers.user; - -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.InviteToken; -import graphql.schema.DataFetcher; -import graphql.schema.DataFetchingEnvironment; -import java.util.concurrent.CompletableFuture; -import lombok.RequiredArgsConstructor; - -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. - */ -@RequiredArgsConstructor -@Deprecated -public class CreateNativeUserInviteTokenResolver 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 Authentication authentication = context.getAuthentication(); - - return CompletableFuture.supplyAsync(() -> { - - try { - return new InviteToken(_inviteTokenService.getInviteToken(null, true, authentication)); - } catch (Exception e) { - throw new RuntimeException("Failed to create 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 c3517b61fcedf..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.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.InviteToken; -import graphql.schema.DataFetcher; -import graphql.schema.DataFetchingEnvironment; -import java.util.concurrent.CompletableFuture; -import lombok.RequiredArgsConstructor; - -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. - */ -@RequiredArgsConstructor -@Deprecated -public class GetNativeUserInviteTokenResolver 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 Authentication authentication = context.getAuthentication(); - - return CompletableFuture.supplyAsync(() -> { - try { - return new InviteToken(_inviteTokenService.getInviteToken(null, false, authentication)); - } catch (Exception e) { - throw new RuntimeException("Failed to get invite token"); - } - }); - } -} diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index c5899d0d80a3e..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 @deprecated(reason: "Use getInviteToken instead") - """ Gets an entity based on its urn """ @@ -494,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 @deprecated(reason: "Use createInviteToken instead") - """ Generates a token that can be shared with existing native users to reset their credentials. """ 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/user/CreateNativeUserInviteTokenResolverTest.java deleted file mode 100644 index e71bc0fcd9109..0000000000000 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/user/CreateNativeUserInviteTokenResolverTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.linkedin.datahub.graphql.resolvers.user; - -import com.datahub.authentication.Authentication; -import com.datahub.authentication.invite.InviteTokenService; -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 CreateNativeUserInviteTokenResolverTest { - - private static final String INVITE_TOKEN = "inviteToken"; - - private InviteTokenService _inviteTokenService; - private CreateNativeUserInviteTokenResolver _resolver; - private DataFetchingEnvironment _dataFetchingEnvironment; - private Authentication _authentication; - - @BeforeMethod - public void setupTest() { - _inviteTokenService = mock(InviteTokenService.class); - _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); - _authentication = mock(Authentication.class); - - _resolver = new CreateNativeUserInviteTokenResolver(_inviteTokenService); - } - - @Test - public void testFailsDenyContext() { - 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); - - assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken()); - } -} 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 80cc0436f4904..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.invite.InviteTokenService; -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 InviteTokenService _inviteTokenService; - private GetNativeUserInviteTokenResolver _resolver; - private DataFetchingEnvironment _dataFetchingEnvironment; - private Authentication _authentication; - - @BeforeMethod - public void setupTest() { - _inviteTokenService = mock(InviteTokenService.class); - _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); - _authentication = mock(Authentication.class); - - _resolver = new GetNativeUserInviteTokenResolver(_inviteTokenService); - } - - @Test - public void testFailsDenyContext() { - 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(false), eq(_authentication))).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 386399427d697..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'; @@ -57,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(); @@ -66,7 +68,7 @@ export const SignUp: React.VFC = () => { const { refreshContext } = useAppConfig(); const [acceptRoleMutation] = useAcceptRoleMutation(); - const acceptRole = useCallback(() => { + const acceptRole = () => { acceptRoleMutation({ variables: { input: { @@ -89,7 +91,7 @@ export const SignUp: React.VFC = () => { duration: 3, }); }); - }, [acceptRoleMutation, inviteToken]); + }; const handleSignUp = useCallback( (values: FormValues) => { @@ -114,7 +116,6 @@ export const SignUp: React.VFC = () => { } isLoggedInVar(true); refreshContext(); - acceptRole(); analytics.event({ type: EventType.SignUpEvent, title: values.title }); return Promise.resolve(); }) @@ -123,12 +124,15 @@ export const SignUp: React.VFC = () => { }) .finally(() => setLoading(false)); }, - [refreshContext, inviteToken, acceptRole], + [refreshContext, inviteToken], ); - if (isLoggedIn && !loading) { - return ; - } + useEffect(() => { + if (isLoggedIn && !loading) { + acceptRole(); + history.push(PageRoutes.ROOT); + } + }); return (
@@ -140,10 +144,10 @@ export const SignUp: React.VFC = () => { {loading && }
Email} + label={} > } data-testid="email" /> diff --git a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx index fd15e24fa3eb4..a731be26b4652 100644 --- a/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx +++ b/datahub-web-react/src/app/identity/user/ViewInviteTokenModal.tsx @@ -1,14 +1,15 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import * as QueryString from 'query-string'; import { useLocation } from 'react-router'; -import { RedoOutlined, UserOutlined } from '@ant-design/icons'; -import { Button, Col, message, Modal, Select, Typography } from 'antd'; -import styled from 'styled-components'; +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 { 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; @@ -16,7 +17,7 @@ const ModalSection = styled.div` padding-bottom: 12px; `; -const ModalSectionHeader = styled(Typography.Text)` +const ModalSectionFooter = styled(Typography.Paragraph)` &&&& { padding: 0px; margin: 0px; @@ -24,27 +25,36 @@ const ModalSectionHeader = styled(Typography.Text)` } `; -const ModalSectionParagraph = styled(Typography.Paragraph)` - &&&& { - padding: 0px; - margin: 0px; - } -`; - -const InviteLinkParagraph = styled(Typography.Paragraph)` +const InviteLinkDiv = styled.div` + margin-top: -12px; display: flex; flex-direction: row; justify-content: flex-start; + gap: 10px; + align-items: center; `; -const CreateInviteTokenButton = styled(Button)` - display: inline-block; - width: 20px; - margin-left: -6px; +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 RefreshButton = styled(Button)` + color: ${ANTD_GRAY[7]}; `; const RoleSelect = styled(Select)` - margin-top: 11px; min-width: 105px; `; @@ -66,8 +76,6 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { const [query, setQuery] = useState(undefined); useEffect(() => setQuery(paramsQuery), [paramsQuery]); const [selectedRole, setSelectedRole] = useState(); - const [isInviteTokenCreated, setIsInviteTokenCreated] = useState(false); - const [createInviteTokenMutation, { data: createInviteTokenData }] = useCreateInviteTokenMutation(); // Code related to listing role options and selecting a role const noRoleText = 'No Role'; @@ -99,17 +107,27 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { ); }); - const onSelectRole = (roleUrn: string) => { - const roleFromMap: DataHubRole = rolesMap.get(roleUrn) as DataHubRole; - setSelectedRole(roleFromMap); - }; - - // Code related to creating an invite token + // Code related to getting or creating an invite token const { data: getInviteTokenData } = useGetInviteTokenQuery({ skip: !visible, variables: { input: { roleUrn: selectedRole?.urn } }, }); + 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: { @@ -118,9 +136,10 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { }, }, }) - .then(({ errors }) => { + .then(({ data, errors }) => { if (!errors) { - setIsInviteTokenCreated(true); + setInviteToken(data?.createInviteToken?.inviteToken || ''); + message.success('Generated new invite link'); } }) .catch((e) => { @@ -132,63 +151,65 @@ export default function ViewInviteTokenModal({ visible, onClose }: Props) { }); }; - const inviteToken = useMemo(() => { - if (isInviteTokenCreated) { - return createInviteTokenData?.createInviteToken?.inviteToken; - } - return getInviteTokenData?.getInviteToken?.inviteToken || ''; - }, [getInviteTokenData, createInviteTokenData, isInviteTokenCreated]); - const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}`; return ( - Invite Users + Share Invite Link } visible={visible} onCancel={onClose} > - Role for users joining with this invite link - - - - - {noRoleText} - - } - value={selectedRole?.urn || undefined} - onChange={(e) => onSelectRole(e as string)} - > - - {mapRoleIcon(noRoleText)} + + + {noRoleText} - - {roleSelectOptions()} - - - - -
{inviteLink}
-
- -
-
- - Generate a new link - - Generate a new invite link! Note, any old links will cease to be active. - - createInviteToken(selectedRole?.urn)} size="small" type="text"> - - + + } + 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/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/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-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 index 0417fc76d8635..b4e7a21387714 100644 --- 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 @@ -5,7 +5,6 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; -import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.key.InviteTokenKey; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; @@ -16,7 +15,6 @@ import com.linkedin.metadata.search.SearchEntity; import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.secret.SecretService; -import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.r2.RemoteInvocationException; import java.net.URISyntaxException; @@ -30,6 +28,7 @@ import lombok.extern.slf4j.Slf4j; import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.entity.AspectUtils.*; @Slf4j @@ -171,14 +170,11 @@ private String createInviteToken(@Nullable final String roleUrnStr, @Nonnull fin inviteTokenAspect.setRole(roleUrn); } - // Ingest inviteToken MCP - final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal(); - inviteTokenProposal.setEntityType(INVITE_TOKEN_ENTITY_NAME); - inviteTokenProposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(inviteTokenKey)); - inviteTokenProposal.setAspectName(INVITE_TOKEN_ASPECT_NAME); - inviteTokenProposal.setAspect(GenericRecordUtils.serializeAspect(inviteTokenAspect)); - inviteTokenProposal.setChangeType(ChangeType.UPSERT); - _entityClient.ingestProposal(inviteTokenProposal, authentication); + // Ingest new InviteToken aspect + final MetadataChangeProposal proposal = + buildMetadataChangeProposal(INVITE_TOKEN_ENTITY_NAME, inviteTokenKey, INVITE_TOKEN_ASPECT_NAME, + inviteTokenAspect); + _entityClient.ingestProposal(proposal, authentication); return inviteTokenStr; } 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 133dea5050ac1..b5e4677fd05c4 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 @@ -19,8 +19,8 @@ import java.time.Instant; import java.util.Base64; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -32,10 +32,7 @@ */ @Slf4j public class NativeUserService { - public static final int LOWERCASE_ASCII_START = 97; - public static final int LOWERCASE_ASCII_END = 122; 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); @@ -141,7 +138,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 = generateRandomLowercaseToken(); corpUserCredentials.setPasswordResetToken(_secretService.encrypt(passwordResetToken)); long expirationTime = Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli(); @@ -211,11 +208,8 @@ byte[] getRandomBytes(int length) { return randomBytes; } - // TODO: Refactor to use UUID.randomUUID().toString(); - String generateRandomLowercaseToken(int length) { - return _secureRandom.ints(length, LOWERCASE_ASCII_START, LOWERCASE_ASCII_END + 1) - .mapToObj(i -> String.valueOf((char) i)) - .collect(Collectors.joining()); + String generateRandomLowercaseToken() { + return UUID.randomUUID().toString(); } byte[] saltPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException { 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 index 278eb4e2a3e8c..4a419e93b9856 100644 --- 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 @@ -4,9 +4,7 @@ import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; 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 java.net.URISyntaxException; @@ -15,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.entity.AspectUtils.*; @Slf4j @@ -38,13 +37,9 @@ public void assignRoleToActor(@Nonnull final String actor, @Nonnull final Urn ro 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); + // Ingest new RoleMembership aspect + final MetadataChangeProposal proposal = + buildMetadataChangeProposal(actorUrn, ROLE_MEMBERSHIP_ASPECT_NAME, roleMembership); _entityClient.ingestProposal(proposal, authentication); } } 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 index 39caff7a188be..47f7ef0e0c1eb 100644 --- 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 @@ -1,8 +1,8 @@ package com.linkedin.gms.factory.auth; import com.datahub.authentication.invite.InviteTokenService; -import com.linkedin.entity.client.JavaEntityClient; 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; 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 index 3842062beaad3..42f3e797c33bd 100644 --- 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 @@ -3,8 +3,8 @@ package com.linkedin.gms.factory.auth; import com.datahub.authorization.role.RoleService; -import com.linkedin.entity.client.JavaEntityClient; 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; From 82bd4a4bc1e7adec7b44ade63bc1a690af5507a9 Mon Sep 17 00:00:00 2001 From: aditya-radhakrishnan Date: Wed, 21 Sep 2022 14:29:43 -0700 Subject: [PATCH 4/6] Refactor hashing and security code --- .../java/com/linkedin/metadata/Constants.java | 6 +- .../metadata/secret/SecretService.java | 55 +++++++++++++++++- .../invite/InviteTokenService.java | 27 ++------- .../user/NativeUserService.java | 56 +++---------------- .../invite/InviteTokenServiceTest.java | 5 ++ .../user/NativeUserServiceTest.java | 17 +++--- .../metadata/boot/steps/IngestRolesStep.java | 4 +- 7 files changed, 84 insertions(+), 86 deletions(-) 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/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-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 index b4e7a21387714..266361b4f455e 100644 --- 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 @@ -18,13 +18,10 @@ import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.r2.RemoteInvocationException; import java.net.URISyntaxException; -import java.security.MessageDigest; -import java.util.Base64; import java.util.Collections; -import java.util.Objects; -import java.util.UUID; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import static com.linkedin.metadata.Constants.*; @@ -32,24 +29,15 @@ @Slf4j +@RequiredArgsConstructor public class InviteTokenService { - private static final String HASHING_ALGORITHM = "SHA-256"; 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; - private final MessageDigest _messageDigest; - private final Base64.Encoder _encoder; - - public InviteTokenService(@Nonnull EntityClient entityClient, @Nonnull SecretService secretService) throws Exception { - _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null"); - _secretService = Objects.requireNonNull(secretService, "secretService must not be null"); - _messageDigest = MessageDigest.getInstance(HASHING_ALGORITHM); - _encoder = Base64.getEncoder(); - } public Urn getInviteTokenUrn(@Nonnull final String inviteTokenStr) throws URISyntaxException { - String hashedInviteTokenStr = hashString(inviteTokenStr); + String hashedInviteTokenStr = _secretService.hashString(inviteTokenStr); String inviteTokenUrnStr = String.format("urn:li:inviteToken:%s", hashedInviteTokenStr); return Urn.createFromString(inviteTokenUrnStr); } @@ -94,11 +82,6 @@ public String getInviteToken(@Nullable final String roleUrnStr, boolean regenera return _secretService.decrypt(inviteToken.getToken()); } - private String hashString(@Nonnull final String str) { - byte[] hashedBytes = _messageDigest.digest(str.getBytes()); - return _encoder.encodeToString(hashedBytes); - } - private com.linkedin.identity.InviteToken getInviteTokenEntity(@Nonnull final Urn inviteTokenUrn, @Nonnull final Authentication authentication) throws RemoteInvocationException, URISyntaxException { final EntityResponse inviteTokenEntity = @@ -159,8 +142,8 @@ private Filter createInviteTokenFilter(@Nonnull final String roleUrnStr) { @Nonnull private String createInviteToken(@Nullable final String roleUrnStr, @Nonnull final Authentication authentication) throws Exception { - String inviteTokenStr = UUID.randomUUID().toString(); - String hashedInviteTokenStr = hashString(inviteTokenStr); + String inviteTokenStr = _secretService.generateUrlSafeToken(INVITE_TOKEN_LENGTH); + String hashedInviteTokenStr = _secretService.hashString(inviteTokenStr); InviteTokenKey inviteTokenKey = new InviteTokenKey(); inviteTokenKey.setId(hashedInviteTokenStr); com.linkedin.identity.InviteToken inviteTokenAspect = 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 b5e4677fd05c4..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 @@ -12,16 +12,12 @@ 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.UUID; import java.util.concurrent.TimeUnit; import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import static com.linkedin.metadata.Constants.*; @@ -31,25 +27,13 @@ * Service responsible for creating, updating and authenticating native DataHub users. */ @Slf4j +@RequiredArgsConstructor public class NativeUserService { - private static final int SALT_TOKEN_LENGTH = 16; - 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 { - _entityService = Objects.requireNonNull(entityService, "entityService must not be null!"); - _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null!"); - _secretService = Objects.requireNonNull(secretService, "secretService must not be null!"); - _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 Authentication authentication) throws Exception { @@ -110,10 +94,10 @@ void updateCorpUserCredentials(@Nonnull Urn userUrn, @Nonnull String password, @ 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 @@ -138,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(); + String passwordResetToken = _secretService.generateUrlSafeToken(PASSWORD_RESET_TOKEN_LENGTH); corpUserCredentials.setPasswordResetToken(_secretService.encrypt(passwordResetToken)); long expirationTime = Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli(); @@ -186,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 @@ -202,30 +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() { - return UUID.randomUUID().toString(); - } - - 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!"); @@ -240,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/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/invite/InviteTokenServiceTest.java index f8de2b406c6e4..2eed108b40300 100644 --- 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 @@ -26,6 +26,7 @@ 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 = @@ -127,6 +128,8 @@ public void getInviteTokenRegenerate() throws Exception { 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); @@ -139,6 +142,8 @@ public void getInviteTokenEmptySearchResult() throws Exception { 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); 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 2bf3c6d445452..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 @@ -27,9 +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 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); @@ -50,16 +52,6 @@ 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( @@ -85,7 +77,9 @@ public void testCreateNativeUserUserAlreadyExists() throws Exception { @Test public void testCreateNativeUserPasses() throws Exception { when(_entityService.exists(any())).thenReturn(false); + 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, SYSTEM_AUTHENTICATION); } @@ -104,7 +98,9 @@ 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()); @@ -209,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/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 36b1726d00535..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 @@ -87,8 +87,8 @@ private void ingestRole(final Urn roleUrn, final DataHubRoleInfo dataHubRoleInfo // 3. Write key & aspect final MetadataChangeProposal keyAspectProposal = new MetadataChangeProposal(); final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(roleUrn); - GenericAspect aspect = GenericRecordUtils.serializeAspect( - EntityKeyUtils.convertUrnToEntityKey(roleUrn, keyAspectSpec.getPegasusSchema())); + GenericAspect aspect = + GenericRecordUtils.serializeAspect(EntityKeyUtils.convertUrnToEntityKey(roleUrn, keyAspectSpec)); keyAspectProposal.setAspect(aspect); keyAspectProposal.setAspectName(keyAspectSpec.getName()); keyAspectProposal.setEntityType(DATAHUB_ROLE_ENTITY_NAME); From d06d345cea5bf3aad2fa98b550274e0f98066cd6 Mon Sep 17 00:00:00 2001 From: aditya-radhakrishnan Date: Thu, 22 Sep 2022 17:12:51 -0700 Subject: [PATCH 5/6] fix smoke tests --- smoke-test/test_e2e.py | 18 +- .../tokens/revokable_access_token_test.py | 266 +++++++++++------- 2 files changed, 171 insertions(+), 113 deletions(-) diff --git a/smoke-test/test_e2e.py b/smoke-test/test_e2e.py index 3aae802d9f209..a646c5bc2f7b2 100644 --- a/smoke-test/test_e2e.py +++ b/smoke-test/test_e2e.py @@ -34,7 +34,7 @@ @pytest.fixture(scope="session") def wait_for_healthchecks(): - wait_for_healthcheck_util() + # wait_for_healthcheck_util() yield @@ -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() From 0849393f009acc33166f17b7328a99e5a1965f00 Mon Sep 17 00:00:00 2001 From: aditya-radhakrishnan Date: Fri, 23 Sep 2022 14:50:20 -0700 Subject: [PATCH 6/6] add to updating datahub --- docs/how/updating-datahub.md | 18 ++++++++++++++++++ .../war/src/main/resources/boot/policies.json | 3 +-- smoke-test/test_e2e.py | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) 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/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 a646c5bc2f7b2..4b313bc3ff73a 100644 --- a/smoke-test/test_e2e.py +++ b/smoke-test/test_e2e.py @@ -34,7 +34,7 @@ @pytest.fixture(scope="session") def wait_for_healthchecks(): - # wait_for_healthcheck_util() + wait_for_healthcheck_util() yield