feat(users): add ability to add native users from the UI (#5097)

Co-authored-by: John Joyce <john@acryl.io>
This commit is contained in:
Aditya Radhakrishnan 2022-06-08 21:13:22 -04:00 committed by GitHub
parent 09fc50674d
commit fdf4e48495
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 2601 additions and 47 deletions

View File

@ -47,6 +47,11 @@ public class AuthUtils {
public static final String PASSWORD = "password";
public static final String ACTOR = "actor";
public static final String ACCESS_TOKEN = "token";
public static final String FULL_NAME = "fullName";
public static final String EMAIL = "email";
public static final String TITLE = "title";
public static final String INVITE_TOKEN = "inviteToken";
public static final String RESET_TOKEN = "resetToken";
/**
* Determines whether the inbound request should be forward to downstream Metadata Service. Today, this simply

View File

@ -0,0 +1,23 @@
package auth;
/**
* Currently, this config enables or disable native user authentication.
*/
public class NativeAuthenticationConfigs {
public static final String NATIVE_AUTHENTICATION_ENABLED_CONFIG_PATH = "auth.native.enabled";
private Boolean _isEnabled = true;
public NativeAuthenticationConfigs(final com.typesafe.config.Config configs) {
if (configs.hasPath(NATIVE_AUTHENTICATION_ENABLED_CONFIG_PATH)
&& Boolean.FALSE.equals(
Boolean.parseBoolean(configs.getValue(NATIVE_AUTHENTICATION_ENABLED_CONFIG_PATH).toString()))) {
_isEnabled = false;
}
}
public boolean isNativeAuthenticationEnabled() {
return _isEnabled;
}
}

View File

@ -23,19 +23,29 @@ import com.datahub.authentication.Authentication;
public class AuthServiceClient {
private static final String GENERATE_SESSION_TOKEN_ENDPOINT = "auth/generateSessionTokenForUser";
private static final String SIGN_UP_ENDPOINT = "auth/signUp";
private static final String RESET_NATIVE_USER_CREDENTIALS_ENDPOINT = "auth/resetNativeUserCredentials";
private static final String VERIFY_NATIVE_USER_CREDENTIALS_ENDPOINT = "auth/verifyNativeUserCredentials";
private static final String ACCESS_TOKEN_FIELD = "accessToken";
private static final String USER_ID_FIELD = "userId";
private static final String USER_URN_FIELD = "userUrn";
private static final String FULL_NAME_FIELD = "fullName";
private static final String EMAIL_FIELD = "email";
private static final String TITLE_FIELD = "title";
private static final String PASSWORD_FIELD = "password";
private static final String INVITE_TOKEN_FIELD = "inviteToken";
private static final String RESET_TOKEN_FIELD = "resetToken";
private static final String IS_NATIVE_USER_CREATED_FIELD = "isNativeUserCreated";
private static final String ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD = "areNativeUserCredentialsReset";
private static final String DOES_PASSWORD_MATCH_FIELD = "doesPasswordMatch";
private final String metadataServiceHost;
private final Integer metadataServicePort;
private final Boolean metadataServiceUseSsl;
private final Authentication systemAuthentication;
public AuthServiceClient(
@Nonnull final String metadataServiceHost,
@Nonnull final Integer metadataServicePort,
@Nonnull final Boolean useSsl,
@Nonnull final Authentication systemAuthentication) {
public AuthServiceClient(@Nonnull final String metadataServiceHost, @Nonnull final Integer metadataServicePort,
@Nonnull final Boolean useSsl, @Nonnull final Authentication systemAuthentication) {
this.metadataServiceHost = Objects.requireNonNull(metadataServiceHost);
this.metadataServicePort = Objects.requireNonNull(metadataServicePort);
this.metadataServiceUseSsl = Objects.requireNonNull(useSsl);
@ -88,6 +98,154 @@ public class AuthServiceClient {
}
}
/**
* Call the Auth Service to create a native Datahub user.
*/
@Nonnull
public boolean signUp(@Nonnull final String userUrn, @Nonnull final String fullName, @Nonnull final String email,
@Nonnull final String title, @Nonnull final String password, @Nonnull final String inviteToken) {
Objects.requireNonNull(userUrn, "userUrn 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");
CloseableHttpClient httpClient = HttpClients.createDefault();
try {
final String protocol = this.metadataServiceUseSsl ? "https" : "http";
final HttpPost request =
new HttpPost(String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort,
SIGN_UP_ENDPOINT));
// Build JSON request to verify credentials for a native user.
String json =
String.format("{ \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\" }",
USER_URN_FIELD, userUrn, FULL_NAME_FIELD, fullName, EMAIL_FIELD, email, TITLE_FIELD, title,
PASSWORD_FIELD, password, INVITE_TOKEN_FIELD, inviteToken);
request.setEntity(new StringEntity(json));
// Add authorization header with DataHub frontend system id and secret.
request.addHeader(Http.HeaderNames.AUTHORIZATION, this.systemAuthentication.getCredentials());
CloseableHttpResponse response = httpClient.execute(request);
final HttpEntity entity = response.getEntity();
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK && entity != null) {
// Successfully generated a token for the User
final String jsonStr = EntityUtils.toString(entity);
return getIsNativeUserCreatedFromJson(jsonStr);
} else {
throw new RuntimeException(
String.format("Bad response from the Metadata Service: %s %s", response.getStatusLine().toString(),
response.getEntity().toString()));
}
} catch (Exception e) {
throw new RuntimeException("Failed to create user", e);
} finally {
try {
httpClient.close();
} catch (Exception e) {
log.warn("Failed to close http client", e);
}
}
}
/**
* Call the Auth Service to reset credentials for a native DataHub user.
*/
@Nonnull
public boolean resetNativeUserCredentials(@Nonnull final String userUrn, @Nonnull final String password,
@Nonnull final String resetToken) {
Objects.requireNonNull(userUrn, "userUrn must not be null");
Objects.requireNonNull(password, "password must not be null");
Objects.requireNonNull(resetToken, "reset token must not be null");
CloseableHttpClient httpClient = HttpClients.createDefault();
try {
final String protocol = this.metadataServiceUseSsl ? "https" : "http";
final HttpPost request = new HttpPost(
String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort,
RESET_NATIVE_USER_CREDENTIALS_ENDPOINT));
// Build JSON request to verify credentials for a native user.
String json =
String.format("{ \"%s\":\"%s\", \"%s\":\"%s\", \"%s\":\"%s\" }", USER_URN_FIELD, userUrn,
PASSWORD_FIELD, password, RESET_TOKEN_FIELD, resetToken);
request.setEntity(new StringEntity(json));
// Add authorization header with DataHub frontend system id and secret.
request.addHeader(Http.HeaderNames.AUTHORIZATION, this.systemAuthentication.getCredentials());
CloseableHttpResponse response = httpClient.execute(request);
final HttpEntity entity = response.getEntity();
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK && entity != null) {
// Successfully generated a token for the User
final String jsonStr = EntityUtils.toString(entity);
return getAreNativeUserCredentialsResetFromJson(jsonStr);
} else {
throw new RuntimeException(
String.format("Bad response from the Metadata Service: %s %s", response.getStatusLine().toString(),
response.getEntity().toString()));
}
} catch (Exception e) {
throw new RuntimeException("Failed to reset credentials for user", e);
} finally {
try {
httpClient.close();
} catch (Exception e) {
log.warn("Failed to close http client", e);
}
}
}
/**
* Call the Auth Service to verify the credentials for a native Datahub user.
*/
@Nonnull
public boolean verifyNativeUserCredentials(@Nonnull final String userUrn, @Nonnull final String password) {
Objects.requireNonNull(userUrn, "userUrn must not be null");
Objects.requireNonNull(password, "password must not be null");
CloseableHttpClient httpClient = HttpClients.createDefault();
try {
final String protocol = this.metadataServiceUseSsl ? "https" : "http";
final HttpPost request = new HttpPost(
String.format("%s://%s:%s/%s", protocol, this.metadataServiceHost, this.metadataServicePort,
VERIFY_NATIVE_USER_CREDENTIALS_ENDPOINT));
// Build JSON request to verify credentials for a native user.
String json =
String.format("{ \"%s\":\"%s\", \"%s\":\"%s\" }", USER_URN_FIELD, userUrn, PASSWORD_FIELD, password);
request.setEntity(new StringEntity(json));
// Add authorization header with DataHub frontend system id and secret.
request.addHeader(Http.HeaderNames.AUTHORIZATION, this.systemAuthentication.getCredentials());
CloseableHttpResponse response = httpClient.execute(request);
final HttpEntity entity = response.getEntity();
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK && entity != null) {
// Successfully generated a token for the User
final String jsonStr = EntityUtils.toString(entity);
return getDoesPasswordMatchFromJson(jsonStr);
} else {
throw new RuntimeException(
String.format("Bad response from the Metadata Service: %s %s", response.getStatusLine().toString(),
response.getEntity().toString()));
}
} catch (Exception e) {
throw new RuntimeException("Failed to verify credentials for user", e);
} finally {
try {
httpClient.close();
} catch (Exception e) {
log.warn("Failed to close http client", e);
}
}
}
private String getAccessTokenFromJson(final String jsonStr) {
ObjectMapper mapper = new ObjectMapper();
try {
@ -97,4 +255,31 @@ public class AuthServiceClient {
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
}
}
private boolean getIsNativeUserCreatedFromJson(final String jsonStr) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readTree(jsonStr).get(IS_NATIVE_USER_CREATED_FIELD).asBoolean();
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
}
}
private boolean getAreNativeUserCredentialsResetFromJson(final String jsonStr) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readTree(jsonStr).get(ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD).asBoolean();
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
}
}
private boolean getDoesPasswordMatchFromJson(final String jsonStr) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readTree(jsonStr).get(DOES_PASSWORD_MATCH_FIELD).asBoolean();
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse JSON received from the MetadataService!");
}
}
}

View File

@ -23,12 +23,12 @@ import play.mvc.Http;
import play.mvc.Result;
import auth.AuthUtils;
import auth.JAASConfigs;
import auth.NativeAuthenticationConfigs;
import auth.sso.SsoManager;
import security.AuthenticationManager;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import javax.naming.NamingException;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
@ -45,6 +45,7 @@ public class AuthenticationController extends Controller {
private final Logger _logger = LoggerFactory.getLogger(AuthenticationController.class.getName());
private final Config _configs;
private final JAASConfigs _jaasConfigs;
private final NativeAuthenticationConfigs _nativeAuthenticationConfigs;
@Inject
private org.pac4j.core.config.Config _ssoConfig;
@ -62,6 +63,7 @@ public class AuthenticationController extends Controller {
public AuthenticationController(@Nonnull Config configs) {
_configs = configs;
_jaasConfigs = new JAASConfigs(configs);
_nativeAuthenticationConfigs = new NativeAuthenticationConfigs(configs);
}
/**
@ -87,9 +89,10 @@ public class AuthenticationController extends Controller {
return redirectToIdentityProvider();
}
// 2. If JAAS auth is enabled, fallback to it
if (_jaasConfigs.isJAASEnabled()) {
return redirect(LOGIN_ROUTE + String.format("?%s=%s", AUTH_REDIRECT_URI_PARAM, encodeRedirectUri(redirectPath)));
// 2. If either JAAS auth or Native auth is enabled, fallback to it
if (_jaasConfigs.isJAASEnabled() || _nativeAuthenticationConfigs.isNativeAuthenticationEnabled()) {
return redirect(
LOGIN_ROUTE + String.format("?%s=%s", AUTH_REDIRECT_URI_PARAM, encodeRedirectUri(redirectPath)));
}
// 3. If no auth enabled, fallback to using default user account & redirect.
@ -109,9 +112,15 @@ public class AuthenticationController extends Controller {
*/
@Nonnull
public Result logIn() {
if (!_jaasConfigs.isJAASEnabled()) {
boolean jaasEnabled = _jaasConfigs.isJAASEnabled();
_logger.debug(String.format("Jaas authentication enabled: %b", jaasEnabled));
boolean nativeAuthenticationEnabled = _nativeAuthenticationConfigs.isNativeAuthenticationEnabled();
_logger.debug(String.format("Native authentication enabled: %b", nativeAuthenticationEnabled));
boolean noAuthEnabled = !jaasEnabled && !nativeAuthenticationEnabled;
if (noAuthEnabled) {
String message = "Neither JAAS nor native authentication is enabled on the server.";
final ObjectNode error = Json.newObject();
error.put("message", "JAAS authentication is not enabled on the server.");
error.put("message", message);
return badRequest(error);
}
@ -120,23 +129,19 @@ public class AuthenticationController extends Controller {
final String password = json.findPath(PASSWORD).textValue();
if (StringUtils.isBlank(username)) {
JsonNode invalidCredsJson = Json.newObject()
.put("message", "User name must not be empty.");
JsonNode invalidCredsJson = Json.newObject().put("message", "User name must not be empty.");
return badRequest(invalidCredsJson);
}
ctx().session().clear();
try {
AuthenticationManager.authenticateUser(username, password);
} catch (NamingException e) {
_logger.error("Authentication error", e);
JsonNode invalidCredsJson = Json.newObject()
.put("message", "Invalid Credentials");
JsonNode invalidCredsJson = Json.newObject().put("message", "Invalid Credentials");
boolean loginSucceeded = tryLogin(username, password);
if (!loginSucceeded) {
return badRequest(invalidCredsJson);
}
final Urn actorUrn = new CorpuserUrn(username);
final String accessToken = _authClient.generateSessionTokenForUser(actorUrn.getId());
ctx().session().put(ACTOR, actorUrn.toString());
@ -147,6 +152,119 @@ public class AuthenticationController extends Controller {
.build());
}
/**
* 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.
*
*/
@Nonnull
public Result signUp() {
boolean nativeAuthenticationEnabled = _nativeAuthenticationConfigs.isNativeAuthenticationEnabled();
_logger.debug(String.format("Native authentication enabled: %b", nativeAuthenticationEnabled));
if (!nativeAuthenticationEnabled) {
String message = "Native authentication is not enabled on the server.";
final ObjectNode error = Json.newObject();
error.put("message", message);
return badRequest(error);
}
final JsonNode json = request().body().asJson();
final String fullName = json.findPath(FULL_NAME).textValue();
final String email = json.findPath(EMAIL).textValue();
final String title = json.findPath(TITLE).textValue();
final String password = json.findPath(PASSWORD).textValue();
final String inviteToken = json.findPath(INVITE_TOKEN).textValue();
if (StringUtils.isBlank(fullName)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Full name must not be empty.");
return badRequest(invalidCredsJson);
}
if (StringUtils.isBlank(email)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Email must not be empty.");
return badRequest(invalidCredsJson);
}
if (StringUtils.isBlank(password)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Password must not be empty.");
return badRequest(invalidCredsJson);
}
if (StringUtils.isBlank(title)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Title must not be empty.");
return badRequest(invalidCredsJson);
}
if (StringUtils.isBlank(inviteToken)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Invite token must not be empty.");
return badRequest(invalidCredsJson);
}
ctx().session().clear();
final Urn userUrn = new CorpuserUrn(email);
final String userUrnString = userUrn.toString();
boolean isNativeUserCreated = _authClient.signUp(userUrnString, fullName, email, title, password, inviteToken);
final String accessToken = _authClient.generateSessionTokenForUser(userUrn.getId());
ctx().session().put(ACTOR, userUrnString);
ctx().session().put(ACCESS_TOKEN, accessToken);
return ok().withCookies(Http.Cookie.builder(ACTOR, userUrnString)
.withHttpOnly(false)
.withMaxAge(Duration.of(30, ChronoUnit.DAYS))
.build());
}
/**
* Create a native user based on a name, email, and password.
*
*/
@Nonnull
public Result resetNativeUserCredentials() {
boolean nativeAuthenticationEnabled = _nativeAuthenticationConfigs.isNativeAuthenticationEnabled();
_logger.debug(String.format("Native authentication enabled: %b", nativeAuthenticationEnabled));
if (!nativeAuthenticationEnabled) {
String message = "Native authentication is not enabled on the server.";
final ObjectNode error = Json.newObject();
error.put("message", message);
return badRequest(error);
}
final JsonNode json = request().body().asJson();
final String email = json.findPath(EMAIL).textValue();
final String password = json.findPath(PASSWORD).textValue();
final String resetToken = json.findPath(RESET_TOKEN).textValue();
if (StringUtils.isBlank(email)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Email must not be empty.");
return badRequest(invalidCredsJson);
}
if (StringUtils.isBlank(password)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Password must not be empty.");
return badRequest(invalidCredsJson);
}
if (StringUtils.isBlank(resetToken)) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Reset token must not be empty.");
return badRequest(invalidCredsJson);
}
ctx().session().clear();
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));
final String accessToken = _authClient.generateSessionTokenForUser(userUrn.getId());
ctx().session().put(ACTOR, userUrnString);
ctx().session().put(ACCESS_TOKEN, accessToken);
return ok().withCookies(Http.Cookie.builder(ACTOR, userUrnString)
.withHttpOnly(false)
.withMaxAge(Duration.of(30, ChronoUnit.DAYS))
.build());
}
private Result redirectToIdentityProvider() {
final PlayWebContext playWebContext = new PlayWebContext(ctx(), _playSessionStore);
final Client<?, ?> client = _ssoManager.getSsoProvider().client();
@ -168,4 +286,30 @@ public class AuthenticationController extends Controller {
throw new RuntimeException(String.format("Failed to encode redirect URI %s", redirectUri), e);
}
}
private boolean tryLogin(String username, String password) {
JsonNode invalidCredsJson = Json.newObject().put("message", "Invalid Credentials");
boolean loginSucceeded = false;
// First try jaas login, if enabled
if (_jaasConfigs.isJAASEnabled()) {
try {
_logger.debug("Attempting jaas authentication");
AuthenticationManager.authenticateJaasUser(username, password);
loginSucceeded = true;
_logger.debug("Jaas authentication successful");
} catch (Exception e) {
_logger.debug("Jaas authentication error", e);
}
}
// If jaas login fails or is disabled, try native auth login
if (_nativeAuthenticationConfigs.isNativeAuthenticationEnabled() && !loginSucceeded) {
final Urn userUrn = new CorpuserUrn(username);
final String userUrnString = userUrn.toString();
loginSucceeded = loginSucceeded || _authClient.verifyNativeUserCredentials(userUrnString, password);
}
return loginSucceeded;
}
}

View File

@ -23,7 +23,7 @@ public class AuthenticationManager {
}
public static void authenticateUser(@Nonnull String userName, @Nonnull String password) throws NamingException {
public static void authenticateJaasUser(@Nonnull String userName, @Nonnull String password) throws NamingException {
Preconditions.checkArgument(!StringUtils.isAnyEmpty(userName), "Username cannot be empty");
try {
JAASLoginService jaasLoginService = new JAASLoginService("WHZ-Authentication");

View File

@ -152,11 +152,13 @@ auth.oidc.readTimeout = ${?AUTH_OIDC_READ_TIMEOUT}
# any username / password combination as valid credentials. To disable this entry point altogether, specify the following config:
#
auth.jaas.enabled = ${?AUTH_JAAS_ENABLED}
auth.native.enabled = ${?AUTH_NATIVE_ENABLED}
#
# To disable all authentication to the app, and proxy all users through a master "datahub" account, make sure that both
# jaas and oidc auth are disabled:
# To disable all authentication to the app, and proxy all users through a master "datahub" account, make sure that,
# jaas, native and oidc auth are disabled:
#
# auth.jaas.enabled = false
# auth.native.enabled = false
# auth.oidc.enabled = false # (or simply omit oidc configurations)
# Login session expiration time

View File

@ -14,6 +14,8 @@ GET /config co
# Authentication in React
GET /authenticate controllers.AuthenticationController.authenticate()
POST /logIn controllers.AuthenticationController.logIn()
POST /signUp controllers.AuthenticationController.signUp()
POST /resetNativeUserCredentials controllers.AuthenticationController.resetNativeUserCredentials()
GET /callback/:protocol controllers.SsoCallbackController.handleCallback(protocol: String)
POST /callback/:protocol controllers.SsoCallbackController.handleCallback(protocol: String)
GET /logOut controllers.CentralLogoutController.executeLogout()

View File

@ -2,6 +2,7 @@ package com.linkedin.datahub.graphql;
import com.datahub.authentication.AuthenticationConfiguration;
import com.datahub.authentication.token.StatefulTokenService;
import com.datahub.authentication.user.NativeUserService;
import com.datahub.authorization.AuthorizationConfiguration;
import com.google.common.collect.ImmutableList;
import com.linkedin.common.VersionedUrn;
@ -163,6 +164,9 @@ import com.linkedin.datahub.graphql.resolvers.type.HyperParameterValueTypeResolv
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;
@ -263,6 +267,7 @@ public class GmsGraphQLEngine {
private final boolean supportsImpactAnalysis;
private final TimeseriesAspectService timeseriesAspectService;
private final TimelineService timelineService;
private final NativeUserService nativeUserService;
private final IngestionConfiguration ingestionConfiguration;
private final AuthenticationConfiguration authenticationConfiguration;
@ -335,6 +340,7 @@ public class GmsGraphQLEngine {
final TimeseriesAspectService timeseriesAspectService,
final EntityRegistry entityRegistry,
final SecretService secretService,
final NativeUserService nativeUserService,
final IngestionConfiguration ingestionConfiguration,
final AuthenticationConfiguration authenticationConfiguration,
final AuthorizationConfiguration authorizationConfiguration,
@ -361,6 +367,7 @@ public class GmsGraphQLEngine {
this.supportsImpactAnalysis = supportsImpactAnalysis;
this.timeseriesAspectService = timeseriesAspectService;
this.timelineService = timelineService;
this.nativeUserService = nativeUserService;
this.ingestionConfiguration = Objects.requireNonNull(ingestionConfiguration);
this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration);
@ -625,6 +632,7 @@ public class GmsGraphQLEngine {
.dataFetcher("getRootGlossaryTerms", new GetRootGlossaryTermsResolver(this.entityClient))
.dataFetcher("getRootGlossaryNodes", new GetRootGlossaryNodesResolver(this.entityClient))
.dataFetcher("entityExists", new EntityExistsResolver(this.entityService))
.dataFetcher("getNativeUserInviteToken", new GetNativeUserInviteTokenResolver(this.nativeUserService))
);
}
@ -699,6 +707,8 @@ public class GmsGraphQLEngine {
.dataFetcher("updateName", new UpdateNameResolver(entityService))
.dataFetcher("addRelatedTerms", new AddRelatedTermsResolver(this.entityService))
.dataFetcher("removeRelatedTerms", new RemoveRelatedTermsResolver(this.entityService))
.dataFetcher("createNativeUserInviteToken", new CreateNativeUserInviteTokenResolver(this.nativeUserService))
.dataFetcher("createNativeUserResetToken", new CreateNativeUserResetTokenResolver(this.nativeUserService))
);
}

View File

@ -33,6 +33,10 @@ public class AuthorizationUtils {
return isAuthorized(context, Optional.empty(), PoliciesConfig.MANAGE_GLOSSARIES_PRIVILEGE);
}
public static boolean canManageUserCredentials(@Nonnull QueryContext context) {
return isAuthorized(context, Optional.empty(), PoliciesConfig.MANAGE_USER_CREDENTIALS_PRIVILEGE);
}
public static boolean isAuthorized(
@Nonnull QueryContext context,
@Nonnull Optional<ResourceSpec> resourceSpec,

View File

@ -19,6 +19,7 @@ import java.net.URISyntaxException;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
import static com.linkedin.datahub.graphql.resolvers.ingest.IngestionAuthUtils.*;
import static com.linkedin.metadata.Constants.*;
@ -63,6 +64,7 @@ public class MeResolver implements DataFetcher<CompletableFuture<AuthenticatedUs
platformPrivileges.setManageTokens(canManageTokens(context));
platformPrivileges.setManageTests(canManageTests(context));
platformPrivileges.setManageGlossaries(canManageGlossaries(context));
platformPrivileges.setManageUserCredentials(canManageUserCredentials(context));
// Construct and return authenticated user object.
final AuthenticatedUser authUser = new AuthenticatedUser();
@ -131,6 +133,14 @@ public class MeResolver implements DataFetcher<CompletableFuture<AuthenticatedUs
return isAuthorized(context.getAuthorizer(), context.getActorUrn(), PoliciesConfig.MANAGE_GLOSSARIES_PRIVILEGE);
}
/**
* Returns true if the authenticated user has privileges to manage user credentials
*/
private boolean canManageUserCredentials(@Nonnull QueryContext context) {
return isAuthorized(context.getAuthorizer(), context.getActorUrn(),
PoliciesConfig.MANAGE_USER_CREDENTIALS_PRIVILEGE);
}
/**
* Returns true if the provided actor is authorized for a particular privilege, false otherwise.
*/

View File

@ -0,0 +1,41 @@
package com.linkedin.datahub.graphql.resolvers.user;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.InviteToken;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*;
/**
* Resolver responsible for creating an invite token that Admins can share with prospective users to create native
* user accounts.
*/
public class CreateNativeUserInviteTokenResolver implements DataFetcher<CompletableFuture<InviteToken>> {
private final NativeUserService _nativeUserService;
public CreateNativeUserInviteTokenResolver(final NativeUserService nativeUserService) {
_nativeUserService = nativeUserService;
}
@Override
public CompletableFuture<InviteToken> get(final DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
return CompletableFuture.supplyAsync(() -> {
if (!canManageUserCredentials(context)) {
throw new AuthorizationException(
"Unauthorized to perform this action. Please contact your DataHub administrator.");
}
try {
String inviteToken = _nativeUserService.generateNativeUserInviteToken(context.getAuthentication());
return new InviteToken(inviteToken);
} catch (Exception e) {
throw new RuntimeException("Failed to generate new invite token");
}
});
}
}

View File

@ -0,0 +1,52 @@
package com.linkedin.datahub.graphql.resolvers.user;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreateNativeUserResetTokenInput;
import com.linkedin.datahub.graphql.generated.ResetToken;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
/**
* Resolver responsible for creating a password reset token that Admins can share with native users to reset their
* credentials.
*/
public class CreateNativeUserResetTokenResolver implements DataFetcher<CompletableFuture<ResetToken>> {
private final NativeUserService _nativeUserService;
public CreateNativeUserResetTokenResolver(final NativeUserService nativeUserService) {
_nativeUserService = nativeUserService;
}
@Override
public CompletableFuture<ResetToken> get(final DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
final CreateNativeUserResetTokenInput input =
bindArgument(environment.getArgument("input"), CreateNativeUserResetTokenInput.class);
final String userUrnString = input.getUserUrn();
Objects.requireNonNull(userUrnString, "No user urn was provided!");
if (!canManageUserCredentials(context)) {
throw new AuthorizationException(
"Unauthorized to perform this action. Please contact your DataHub administrator.");
}
return CompletableFuture.supplyAsync(() -> {
try {
String resetToken =
_nativeUserService.generateNativeUserPasswordResetToken(userUrnString, context.getAuthentication());
return new ResetToken(resetToken);
} catch (Exception e) {
throw new RuntimeException(
String.format("Failed to generate password reset token for user: %s", userUrnString));
}
});
}
}

View File

@ -0,0 +1,42 @@
package com.linkedin.datahub.graphql.resolvers.user;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.InviteToken;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*;
/**
* Resolver responsible for getting an existing invite token that Admins can share with prospective users to create
* native user accounts. If the invite token does not already exist, this resolver will create a new one.
*/
public class GetNativeUserInviteTokenResolver implements DataFetcher<CompletableFuture<InviteToken>> {
private final NativeUserService _nativeUserService;
public GetNativeUserInviteTokenResolver(final NativeUserService nativeUserService) {
_nativeUserService = nativeUserService;
}
@Override
public CompletableFuture<InviteToken> get(final DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
return CompletableFuture.supplyAsync(() -> {
if (!canManageUserCredentials(context)) {
throw new AuthorizationException(
"Unauthorized to perform this action. Please contact your DataHub administrator.");
}
try {
String inviteToken = _nativeUserService.getNativeUserInviteToken(context.getAuthentication());
return new InviteToken(inviteToken);
} catch (Exception e) {
throw new RuntimeException("Failed to generate new invite token");
}
});
}
}

View File

@ -10,6 +10,7 @@ import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.EnvelopedAspectMap;
import com.linkedin.identity.CorpUserCredentials;
import com.linkedin.identity.CorpUserEditableInfo;
import com.linkedin.identity.CorpUserInfo;
import com.linkedin.identity.CorpUserStatus;
@ -45,8 +46,9 @@ public class CorpUserMapper implements ModelMapper<EntityResponse, CorpUser> {
corpUser.setEditableProperties(CorpUserEditableInfoMapper.map(new CorpUserEditableInfo(dataMap))));
mappingHelper.mapToResult(GLOBAL_TAGS_ASPECT_NAME, (corpUser, dataMap) ->
corpUser.setGlobalTags(GlobalTagsMapper.map(new GlobalTags(dataMap))));
mappingHelper.mapToResult(CORP_USER_STATUS_ASPECT_NAME, (corpUser, dataMap) ->
corpUser.setStatus(CorpUserStatusMapper.map(new CorpUserStatus(dataMap))));
mappingHelper.mapToResult(CORP_USER_STATUS_ASPECT_NAME,
(corpUser, dataMap) -> corpUser.setStatus(CorpUserStatusMapper.map(new CorpUserStatus(dataMap))));
mappingHelper.mapToResult(CORP_USER_CREDENTIALS_ASPECT_NAME, this::mapIsNativeUser);
return mappingHelper.getResult();
}
@ -60,4 +62,11 @@ public class CorpUserMapper implements ModelMapper<EntityResponse, CorpUser> {
corpUser.setProperties(CorpUserPropertiesMapper.map(corpUserInfo));
corpUser.setInfo(CorpUserInfoMapper.map(corpUserInfo));
}
private void mapIsNativeUser(@Nonnull CorpUser corpUser, @Nonnull DataMap dataMap) {
CorpUserCredentials corpUserCredentials = new CorpUserCredentials(dataMap);
boolean isNativeUser =
corpUserCredentials != null && corpUserCredentials.hasSalt() && corpUserCredentials.hasHashedPassword();
corpUser.setIsNativeUser(isNativeUser);
}
}

View File

@ -81,6 +81,11 @@ type PlatformPrivileges {
Whether the user should be able to manage Glossaries
"""
manageGlossaries: Boolean!
"""
Whether the user is able to manage user credentials
"""
manageUserCredentials: Boolean!
}
"""

View File

@ -162,7 +162,12 @@ type Query {
"""
Get whether or not not an entity exists
"""
entityExisst(urn: String!): Boolean
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
}
"""
@ -365,7 +370,7 @@ type Mutation {
Input required to report an operation
"""
input: ReportOperationInput!): String
"""
Create a new GlossaryTerm. Returns the urn of the newly created GlossaryTerm. If a term with the provided ID already exists, it will be overwritten.
"""
@ -400,6 +405,16 @@ type Mutation {
Remove multiple related Terms for a Glossary Term
"""
removeRelatedTerms(input: RelatedTermsInput!): Boolean
"""
Generates an invite token that can be shared with prospective users to create their accounts.
"""
createNativeUserInviteToken: InviteToken
"""
Generates a token that can be shared with existing native users to reset their credentials.
"""
createNativeUserResetToken(input: CreateNativeUserResetTokenInput!): ResetToken
}
"""
@ -2672,6 +2687,11 @@ type CorpUser implements Entity {
"""
relationships(input: RelationshipsInput!): EntityRelationshipsResult
"""
Whether or not this user is a native DataHub user
"""
isNativeUser: Boolean
"""
Deprecated, use properties field instead
Additional read only info about the corp user
@ -8192,3 +8212,33 @@ input StringMapEntryInput {
"""
value: String
}
"""
Token that allows users to sign up as a native user
"""
type InviteToken {
"""
The invite token
"""
inviteToken: String!
}
"""
Input required to generate a password reset token for a native user.
"""
input CreateNativeUserResetTokenInput {
"""
The urn of the user to reset the password of
"""
userUrn: String!
}
"""
Token that allows native users to reset their credentials
"""
type ResetToken {
"""
The reset token
"""
resetToken: String!
}

View File

@ -0,0 +1,50 @@
package com.linkedin.datahub.graphql.resolvers.user;
import com.datahub.authentication.Authentication;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.QueryContext;
import graphql.schema.DataFetchingEnvironment;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
public class CreateNativeUserInviteTokenResolverTest {
private static final String INVITE_TOKEN = "inviteToken";
private NativeUserService _nativeUserService;
private CreateNativeUserInviteTokenResolver _resolver;
private DataFetchingEnvironment _dataFetchingEnvironment;
private Authentication _authentication;
@BeforeMethod
public void setupTest() {
_nativeUserService = mock(NativeUserService.class);
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
_authentication = mock(Authentication.class);
_resolver = new CreateNativeUserInviteTokenResolver(_nativeUserService);
}
@Test
public void testFailsCannotManageUserCredentials() {
QueryContext mockContext = getMockDenyContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
}
@Test
public void testPasses() throws Exception {
QueryContext mockContext = getMockAllowContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
when(mockContext.getAuthentication()).thenReturn(_authentication);
when(_nativeUserService.generateNativeUserInviteToken(any())).thenReturn(INVITE_TOKEN);
assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken());
}
}

View File

@ -0,0 +1,66 @@
package com.linkedin.datahub.graphql.resolvers.user;
import com.datahub.authentication.Authentication;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.CreateNativeUserResetTokenInput;
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 CreateNativeUserResetTokenResolverTest {
private static final String RESET_TOKEN = "resetToken";
private static final String USER_URN_STRING = "urn:li:corpuser:test";
private NativeUserService _nativeUserService;
private CreateNativeUserResetTokenResolver _resolver;
private DataFetchingEnvironment _dataFetchingEnvironment;
private Authentication _authentication;
@BeforeMethod
public void setupTest() {
_nativeUserService = mock(NativeUserService.class);
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
_authentication = mock(Authentication.class);
_resolver = new CreateNativeUserResetTokenResolver(_nativeUserService);
}
@Test
public void testFailsCannotManageUserCredentials() {
QueryContext mockContext = getMockDenyContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
}
@Test
public void testFailsNullUserUrn() throws Exception {
QueryContext mockContext = getMockAllowContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
CreateNativeUserResetTokenInput input = new CreateNativeUserResetTokenInput(null);
when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input);
when(mockContext.getAuthentication()).thenReturn(_authentication);
when(_nativeUserService.generateNativeUserPasswordResetToken(any(), any())).thenReturn(RESET_TOKEN);
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
}
@Test
public void testPasses() throws Exception {
QueryContext mockContext = getMockAllowContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
CreateNativeUserResetTokenInput input = new CreateNativeUserResetTokenInput(USER_URN_STRING);
when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input);
when(mockContext.getAuthentication()).thenReturn(_authentication);
when(_nativeUserService.generateNativeUserPasswordResetToken(any(), any())).thenReturn(RESET_TOKEN);
assertEquals(RESET_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getResetToken());
}
}

View File

@ -0,0 +1,50 @@
package com.linkedin.datahub.graphql.resolvers.user;
import com.datahub.authentication.Authentication;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.QueryContext;
import graphql.schema.DataFetchingEnvironment;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
public class GetNativeUserInviteTokenResolverTest {
private static final String INVITE_TOKEN = "inviteToken";
private NativeUserService _nativeUserService;
private GetNativeUserInviteTokenResolver _resolver;
private DataFetchingEnvironment _dataFetchingEnvironment;
private Authentication _authentication;
@BeforeMethod
public void setupTest() {
_nativeUserService = mock(NativeUserService.class);
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
_authentication = mock(Authentication.class);
_resolver = new GetNativeUserInviteTokenResolver(_nativeUserService);
}
@Test
public void testFailsCannotManageUserCredentials() {
QueryContext mockContext = getMockDenyContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
}
@Test
public void testPasses() throws Exception {
QueryContext mockContext = getMockAllowContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
when(mockContext.getAuthentication()).thenReturn(_authentication);
when(_nativeUserService.getNativeUserInviteToken(any())).thenReturn(INVITE_TOKEN);
assertEquals(INVITE_TOKEN, _resolver.get(_dataFetchingEnvironment).join().getInviteToken());
}
}

View File

@ -2,6 +2,8 @@ import React from 'react';
import { Switch, Route, RouteProps, Redirect } from 'react-router-dom';
import { useReactiveVar } from '@apollo/client';
import { LogIn } from './auth/LogIn';
import { SignUp } from './auth/SignUp';
import { ResetCredentials } from './auth/ResetCredentials';
import { NoPageFound } from './shared/NoPageFound';
import { PageRoutes } from '../conf/Global';
import { isLoggedInVar } from './auth/checkAuthStatus';
@ -32,6 +34,8 @@ export const Routes = (): JSX.Element => {
return (
<Switch>
<Route path={PageRoutes.LOG_IN} component={LogIn} />
<Route path={PageRoutes.SIGN_UP} component={SignUp} />
<Route path={PageRoutes.RESET_CREDENTIALS} component={ResetCredentials} />
<ProtectedRoute isLoggedIn={isLoggedIn} render={() => <ProtectedRoutes />} />
{/* Starting the react app locally opens /assets by default. For a smoother dev experience, we'll redirect to the homepage */}
<Route path={PageRoutes.ASSETS} component={() => <Redirect to="/" />} exact />

View File

@ -20,6 +20,8 @@ export enum EventType {
SearchAcrossLineageEvent,
SearchAcrossLineageResultsViewEvent,
DownloadAsCsvEvent,
SignUpEvent,
ResetCredentialsEvent,
}
/**
@ -40,6 +42,14 @@ export interface PageViewEvent extends BaseEvent {
type: EventType.PageViewEvent;
}
/**
* Logged on successful new user sign up.
*/
export interface SignUpEvent extends BaseEvent {
type: EventType.SignUpEvent;
title: string;
}
/**
* Logged on user successful login.
*/
@ -54,6 +64,13 @@ export interface LogOutEvent extends BaseEvent {
type: EventType.LogOutEvent;
}
/**
* Logged on user resetting their credentials
*/
export interface ResetCredentialsEvent extends BaseEvent {
type: EventType.ResetCredentialsEvent;
}
/**
* Logged on user successful search query.
*/
@ -189,8 +206,10 @@ export interface DownloadAsCsvEvent extends BaseEvent {
*/
export type Event =
| PageViewEvent
| SignUpEvent
| LogInEvent
| LogOutEvent
| ResetCredentialsEvent
| SearchEvent
| SearchResultsViewEvent
| SearchResultClickEvent

View File

@ -0,0 +1,169 @@
import React, { useCallback, useState } from 'react';
import { Input, Button, Form, message, Image } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useReactiveVar } from '@apollo/client';
import styled, { useTheme } from 'styled-components';
import { Redirect } from 'react-router';
import styles from './login.module.css';
import { Message } from '../shared/Message';
import { isLoggedInVar } from './checkAuthStatus';
import analytics, { EventType } from '../analytics';
import { useAppConfig } from '../useAppConfig';
import { PageRoutes } from '../../conf/Global';
import useGetResetTokenFromUrlParams from './useGetResetTokenFromUrlParams';
type FormValues = {
email: string;
password: string;
confirmPassword: string;
};
const FormInput = styled(Input)`
&&& {
height: 32px;
font-size: 12px;
border: 1px solid #555555;
border-radius: 5px;
background-color: transparent;
color: white;
line-height: 1.5715;
}
> .ant-input {
color: white;
font-size: 14px;
background-color: transparent;
}
> .ant-input:hover {
color: white;
font-size: 14px;
background-color: transparent;
}
`;
export type ResetCredentialsProps = Record<string, never>;
export const ResetCredentials: React.VFC<ResetCredentialsProps> = () => {
const isLoggedIn = useReactiveVar(isLoggedInVar);
const resetToken = useGetResetTokenFromUrlParams();
const themeConfig = useTheme();
const [loading, setLoading] = useState(false);
const { refreshContext } = useAppConfig();
const handleResetCredentials = useCallback(
(values: FormValues) => {
setLoading(true);
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: values.email,
password: values.password,
resetToken,
}),
};
fetch('/resetNativeUserCredentials', requestOptions)
.then(async (response) => {
if (!response.ok) {
const data = await response.json();
const error = (data && data.message) || response.status;
return Promise.reject(error);
}
isLoggedInVar(true);
refreshContext();
analytics.event({ type: EventType.ResetCredentialsEvent });
return Promise.resolve();
})
.catch((error) => {
message.error(`Failed to log in! ${error}`);
})
.finally(() => setLoading(false));
},
[refreshContext, resetToken],
);
if (isLoggedIn && !loading) {
return <Redirect to={`${PageRoutes.ROOT}`} />;
}
return (
<div className={styles.login_page}>
<div className={styles.login_box}>
<div className={styles.login_logo_box}>
<Image wrapperClassName={styles.logo_image} src={themeConfig.assets?.logoUrl} preview={false} />
</div>
<div className={styles.login_form_box}>
{loading && <Message type="loading" content="Resetting credentials..." />}
<Form onFinish={handleResetCredentials} layout="vertical">
<Form.Item
rules={[{ required: true, message: 'Please fill in your email!' }]}
name="email"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Email</label>}
>
<FormInput prefix={<UserOutlined />} data-testid="email" />
</Form.Item>
<Form.Item
rules={[
{ required: true, message: 'Please fill in your password!' },
({ getFieldValue }) => ({
validator() {
if (getFieldValue('password').length < 8) {
return Promise.reject(
new Error('Your password is less than 8 characters!'),
);
}
return Promise.resolve();
},
}),
]}
name="password"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Password</label>}
>
<FormInput prefix={<LockOutlined />} type="password" data-testid="password" />
</Form.Item>
<Form.Item
rules={[
{ required: true, message: 'Please confirm your password!' },
({ getFieldValue }) => ({
validator() {
if (getFieldValue('confirmPassword') !== getFieldValue('password')) {
return Promise.reject(new Error('Your passwords do not match!'));
}
return Promise.resolve();
},
}),
]}
name="confirmPassword"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Confirm Password</label>}
>
<FormInput prefix={<LockOutlined />} type="password" data-testid="confirmPassword" />
</Form.Item>
<Form.Item style={{ marginBottom: '0px' }} shouldUpdate>
{({ getFieldsValue }) => {
const { email, password, confirmPassword } = getFieldsValue();
const fieldsAreNotEmpty = !!email && !!password && !!confirmPassword;
const passwordsMatch = password === confirmPassword;
const formIsComplete = fieldsAreNotEmpty && passwordsMatch;
return (
<Button
type="primary"
block
htmlType="submit"
className={styles.login_button}
disabled={!formIsComplete}
>
Reset credentials
</Button>
);
}}
</Form.Item>
</Form>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,209 @@
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';
import styled, { useTheme } from 'styled-components/macro';
import { Redirect } from 'react-router';
import styles from './login.module.css';
import { Message } from '../shared/Message';
import { isLoggedInVar } from './checkAuthStatus';
import analytics, { EventType } from '../analytics';
import { useAppConfig } from '../useAppConfig';
import { PageRoutes } from '../../conf/Global';
import useGetInviteTokenFromUrlParams from './useGetInviteTokenFromUrlParams';
type FormValues = {
fullName: string;
email: string;
password: string;
confirmPassword: string;
title: string;
};
const FormInput = styled(Input)`
&&& {
height: 32px;
font-size: 12px;
border: 1px solid #555555;
border-radius: 5px;
background-color: transparent;
color: white;
line-height: 1.5715;
}
> .ant-input {
color: white;
font-size: 14px;
background-color: transparent;
}
> .ant-input:hover {
color: white;
font-size: 14px;
background-color: transparent;
}
`;
const TitleSelector = styled(Select)`
.ant-select-selector {
color: white;
border: 1px solid #555555 !important;
background-color: transparent !important;
}
.ant-select-arrow {
color: white;
}
`;
export type SignUpProps = Record<string, never>;
export const SignUp: React.VFC<SignUpProps> = () => {
const isLoggedIn = useReactiveVar(isLoggedInVar);
const inviteToken = useGetInviteTokenFromUrlParams();
const themeConfig = useTheme();
const [loading, setLoading] = useState(false);
const { refreshContext } = useAppConfig();
const handleSignUp = useCallback(
(values: FormValues) => {
setLoading(true);
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullName: values.fullName,
email: values.email,
password: values.password,
title: values.title,
inviteToken,
}),
};
fetch('/signUp', requestOptions)
.then(async (response) => {
if (!response.ok) {
const data = await response.json();
const error = (data && data.message) || response.status;
return Promise.reject(error);
}
isLoggedInVar(true);
refreshContext();
analytics.event({ type: EventType.SignUpEvent, title: values.title });
return Promise.resolve();
})
.catch((error) => {
message.error(`Failed to log in! ${error}`);
})
.finally(() => setLoading(false));
},
[refreshContext, inviteToken],
);
if (isLoggedIn && !loading) {
return <Redirect to={`${PageRoutes.ROOT}`} />;
}
return (
<div className={styles.login_page}>
<div className={styles.login_box}>
<div className={styles.login_logo_box}>
<Image wrapperClassName={styles.logo_image} src={themeConfig.assets?.logoUrl} preview={false} />
</div>
<div className={styles.login_form_box}>
{loading && <Message type="loading" content="Signing up..." />}
<Form onFinish={handleSignUp} layout="vertical">
<Form.Item
rules={[{ required: true, message: 'Please fill in your email!' }]}
name="email"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Email</label>}
>
<FormInput prefix={<UserOutlined />} data-testid="email" />
</Form.Item>
<Form.Item
rules={[{ required: true, message: 'Please fill in your name!' }]}
name="fullName"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Full Name</label>}
>
<FormInput prefix={<UserOutlined />} data-testid="name" />
</Form.Item>
<Form.Item
rules={[
{ required: true, message: 'Please fill in your password!' },
({ getFieldValue }) => ({
validator() {
if (getFieldValue('password').length < 8) {
return Promise.reject(
new Error('Your password is less than 8 characters!'),
);
}
return Promise.resolve();
},
}),
]}
name="password"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Password</label>}
>
<FormInput prefix={<LockOutlined />} type="password" data-testid="password" />
</Form.Item>
<Form.Item
rules={[
{ required: true, message: 'Please confirm your password!' },
({ getFieldValue }) => ({
validator() {
if (getFieldValue('confirmPassword') !== getFieldValue('password')) {
return Promise.reject(new Error('Your passwords do not match!'));
}
return Promise.resolve();
},
}),
]}
name="confirmPassword"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Confirm Password</label>}
>
<FormInput prefix={<LockOutlined />} type="password" data-testid="confirmPassword" />
</Form.Item>
<Form.Item
rules={[{ required: true, message: 'Please fill in your title!' }]}
name="title"
// eslint-disable-next-line jsx-a11y/label-has-associated-control
label={<label style={{ color: 'white' }}>Title</label>}
>
<TitleSelector placeholder="Title">
<Select.Option value="Data Analyst">Data Analyst</Select.Option>
<Select.Option value="Data Engineer">Data Engineer</Select.Option>
<Select.Option value="Data Scientist">Data Scientist</Select.Option>
<Select.Option value="Software Engineer">Software Engineer</Select.Option>
<Select.Option value="Manager">Manager</Select.Option>
<Select.Option value="Product Manager">Product Manager</Select.Option>
<Select.Option value="Other">Other</Select.Option>
</TitleSelector>
</Form.Item>
<Form.Item style={{ marginBottom: '0px' }} shouldUpdate>
{({ getFieldsValue }) => {
const { fullName, email, password, confirmPassword, title } = getFieldsValue();
const fieldsAreNotEmpty =
!!fullName && !!email && !!password && !!confirmPassword && !!title;
const passwordsMatch = password === confirmPassword;
const formIsComplete = fieldsAreNotEmpty && passwordsMatch;
return (
<Button
type="primary"
block
htmlType="submit"
className={styles.login_button}
disabled={!formIsComplete}
>
Sign Up!
</Button>
);
}}
</Form.Item>
</Form>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,9 @@
import * as QueryString from 'query-string';
import { useLocation } from 'react-router-dom';
export default function useGetInviteTokenFromUrlParams() {
const location = useLocation();
const params = QueryString.parse(location.search, { arrayFormat: 'comma' });
const inviteToken: string = params.invite_token as string;
return inviteToken;
}

View File

@ -0,0 +1,9 @@
import * as QueryString from 'query-string';
import { useLocation } from 'react-router-dom';
export default function useGetResetTokenFromUrlParams() {
const location = useLocation();
const params = QueryString.parse(location.search, { arrayFormat: 'comma' });
const resetToken: string = params.reset_token as string;
return resetToken;
}

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Empty, List, message, Pagination } from 'antd';
import { Button, Empty, List, message, Pagination } from 'antd';
import styled from 'styled-components';
import * as QueryString from 'query-string';
import { UsergroupAddOutlined } from '@ant-design/icons';
import { useLocation } from 'react-router';
import UserListItem from './UserListItem';
import { Message } from '../../shared/Message';
@ -10,6 +11,8 @@ import { CorpUser } from '../../../types.generated';
import TabToolbar from '../../entity/shared/components/styled/TabToolbar';
import { SearchBar } from '../../search/SearchBar';
import { useEntityRegistry } from '../../useEntityRegistry';
import ViewInviteTokenModal from './ViewInviteTokenModal';
import { useGetAuthenticatedUser } from '../../useGetAuthenticatedUser';
const UserContainer = styled.div``;
@ -36,8 +39,12 @@ export const UserList = () => {
useEffect(() => setQuery(paramsQuery), [paramsQuery]);
const [page, setPage] = useState(1);
const [isViewingInviteToken, setIsViewingInviteToken] = useState(false);
const [removedUrns, setRemovedUrns] = useState<string[]>([]);
const authenticatedUser = useGetAuthenticatedUser();
const canManageUserCredentials = authenticatedUser?.platformPrivileges.manageUserCredentials || false;
const pageSize = DEFAULT_PAGE_SIZE;
const start = (page - 1) * pageSize;
@ -76,7 +83,13 @@ export const UserList = () => {
<UserContainer>
<TabToolbar>
<div>
<></>
<Button
disabled={!canManageUserCredentials}
type="text"
onClick={() => setIsViewingInviteToken(true)}
>
<UsergroupAddOutlined /> Invite Users
</Button>
</div>
<SearchBar
initialQuery={query || ''}
@ -102,7 +115,11 @@ export const UserList = () => {
}}
dataSource={filteredUsers}
renderItem={(item: any) => (
<UserListItem onDelete={() => handleDelete(item.urn as string)} user={item as CorpUser} />
<UserListItem
onDelete={() => handleDelete(item.urn as string)}
user={item as CorpUser}
canManageUserCredentials={canManageUserCredentials}
/>
)}
/>
<UserPaginationContainer>
@ -116,6 +133,7 @@ export const UserList = () => {
showSizeChanger={false}
/>
</UserPaginationContainer>
<ViewInviteTokenModal visible={isViewingInviteToken} onClose={() => setIsViewingInviteToken(false)} />
</UserContainer>
</>
);

View File

@ -1,16 +1,18 @@
import React from 'react';
import React, { useState } from 'react';
import styled from 'styled-components';
import { Button, List, message, Modal, Tag, Tooltip, Typography } from 'antd';
import { Button, Dropdown, List, Menu, message, Modal, Tag, Tooltip, Typography } from 'antd';
import { Link } from 'react-router-dom';
import { DeleteOutlined } from '@ant-design/icons';
import { DeleteOutlined, MoreOutlined, UnlockOutlined } from '@ant-design/icons';
import { CorpUser, CorpUserStatus, EntityType } from '../../../types.generated';
import CustomAvatar from '../../shared/avatar/CustomAvatar';
import { useEntityRegistry } from '../../useEntityRegistry';
import { useRemoveUserMutation } from '../../../graphql/user.generated';
import { ANTD_GRAY, REDESIGN_COLORS } from '../../entity/shared/constants';
import ViewResetTokenModal from './ViewResetTokenModal';
type Props = {
user: CorpUser;
canManageUserCredentials: boolean;
onDelete?: () => void;
};
@ -34,9 +36,12 @@ const ButtonGroup = styled.div`
align-items: center;
`;
export default function UserListItem({ user, onDelete }: Props) {
export default function UserListItem({ user, canManageUserCredentials, onDelete }: Props) {
const entityRegistry = useEntityRegistry();
const [isViewingResetToken, setIsViewingResetToken] = useState(false);
const displayName = entityRegistry.getDisplayName(EntityType.CorpUser, user);
const isNativeUser: boolean = user.isNativeUser as boolean;
const shouldShowPasswordReset: boolean = canManageUserCredentials && isNativeUser;
const [removeUserMutation] = useRemoveUserMutation();
@ -118,10 +123,28 @@ export default function UserListItem({ user, onDelete }: Props) {
</Link>
</UserItemContainer>
<ButtonGroup>
<Dropdown
trigger={['click']}
overlay={
<Menu>
<Menu.Item disabled={!shouldShowPasswordReset} onClick={() => setIsViewingResetToken(true)}>
<UnlockOutlined /> &nbsp; Reset user password
</Menu.Item>
</Menu>
}
>
<MoreOutlined />
</Dropdown>
<Button onClick={() => handleRemoveUser(user.urn)} type="text" shape="circle" danger>
<DeleteOutlined />
</Button>
</ButtonGroup>
<ViewResetTokenModal
visible={isViewingResetToken}
userUrn={user.urn}
username={user.username}
onClose={() => setIsViewingResetToken(false)}
/>
</List.Item>
);
}

View File

@ -0,0 +1,88 @@
import { RedoOutlined } from '@ant-design/icons';
import { Button, Modal, Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { PageRoutes } from '../../../conf/Global';
import {
useCreateNativeUserInviteTokenMutation,
useGetNativeUserInviteTokenQuery,
} from '../../../graphql/user.generated';
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 = {
visible: boolean;
onClose: () => void;
};
export default function ViewInviteTokenModal({ visible, onClose }: Props) {
const baseUrl = window.location.origin;
const { data: getNativeUserInviteTokenData } = useGetNativeUserInviteTokenQuery({});
const [createNativeUserInviteToken, { data: createNativeUserInviteTokenData }] =
useCreateNativeUserInviteTokenMutation({});
const inviteToken = createNativeUserInviteTokenData?.createNativeUserInviteToken?.inviteToken
? createNativeUserInviteTokenData?.createNativeUserInviteToken.inviteToken
: getNativeUserInviteTokenData?.getNativeUserInviteToken?.inviteToken || '';
const inviteLink = `${baseUrl}${PageRoutes.SIGN_UP}?invite_token=${inviteToken}`;
return (
<Modal
width={700}
footer={null}
title={
<Typography.Text>
<b>Invite new DataHub users</b>
</Typography.Text>
}
visible={visible}
onCancel={onClose}
>
<ModalSection>
<ModalSectionHeader strong>Share invite link</ModalSectionHeader>
<ModalSectionParagraph>
Share this invite link with other users in your workspace!
</ModalSectionParagraph>
<Typography.Paragraph copyable={{ text: inviteLink }}>
<pre>{inviteLink}</pre>
</Typography.Paragraph>
</ModalSection>
<ModalSection>
<ModalSectionHeader strong>Generate a new link</ModalSectionHeader>
<ModalSectionParagraph>
Generate a new invite link! Note, any old links will <b>cease to be active</b>.
</ModalSectionParagraph>
<CreateInviteTokenButton onClick={() => createNativeUserInviteToken({})} size="small" type="text">
<RedoOutlined style={{}} />
</CreateInviteTokenButton>
</ModalSection>
</Modal>
);
}

View File

@ -0,0 +1,108 @@
import { RedoOutlined } from '@ant-design/icons';
import { Button, Modal, Typography } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components';
import { PageRoutes } from '../../../conf/Global';
import { useCreateNativeUserResetTokenMutation } from '../../../graphql/user.generated';
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 CreateResetTokenButton = styled(Button)`
display: inline-block;
width: 20px;
margin-left: -6px;
`;
type Props = {
visible: boolean;
userUrn: string;
username: string;
onClose: () => void;
};
export default function ViewResetTokenModal({ visible, userUrn, username, onClose }: Props) {
const baseUrl = window.location.origin;
const [hasGeneratedResetToken, setHasGeneratedResetToken] = useState(false);
const [createNativeUserResetToken, { data: createNativeUserResetTokenData }] =
useCreateNativeUserResetTokenMutation({});
const resetToken = createNativeUserResetTokenData?.createNativeUserResetToken?.resetToken || '';
const inviteLink = `${baseUrl}${PageRoutes.RESET_CREDENTIALS}?reset_token=${resetToken}`;
return (
<Modal
width={700}
footer={null}
title={
<Typography.Text>
<b>Reset User Password</b>
</Typography.Text>
}
visible={visible}
onCancel={onClose}
>
{hasGeneratedResetToken ? (
<ModalSection>
<ModalSectionHeader strong>Share reset link</ModalSectionHeader>
<ModalSectionParagraph>
Share this reset link to reset the credentials for {username}.
<b>This link will expire in 24 hours.</b>
</ModalSectionParagraph>
<Typography.Paragraph copyable={{ text: inviteLink }}>
<pre>{inviteLink}</pre>
</Typography.Paragraph>
</ModalSection>
) : (
<ModalSection>
<ModalSectionHeader strong>A new link must be generated</ModalSectionHeader>
<ModalSectionParagraph>
You cannot view any old reset links. Please generate a new one below.
</ModalSectionParagraph>
</ModalSection>
)}
<ModalSection>
<ModalSectionHeader strong>Generate a new link</ModalSectionHeader>
<ModalSectionParagraph>
Generate a new reset link! Note, any old links will <b>cease to be active</b>.
</ModalSectionParagraph>
<CreateResetTokenButton
onClick={() => {
createNativeUserResetToken({
variables: {
input: {
userUrn,
},
},
});
setHasGeneratedResetToken(true);
}}
size="small"
type="text"
>
<RedoOutlined style={{}} />
</CreateResetTokenButton>
</ModalSection>
</Modal>
);
}

View File

@ -5,8 +5,11 @@ export enum PageRoutes {
/**
* Server-side authentication route
*/
ROOT = '/',
AUTHENTICATE = '/authenticate',
SIGN_UP = '/signup',
LOG_IN = '/login',
RESET_CREDENTIALS = '/reset',
SEARCH_RESULTS = '/search/:type?',
SEARCH = '/search',
BROWSE = '/browse',

View File

@ -30,6 +30,7 @@ query getMe {
manageDomains
manageTests
manageGlossaries
manageUserCredentials
}
}
}

View File

@ -2,6 +2,7 @@ query getUser($urn: String!, $groupsCount: Int!) {
corpUser(urn: $urn) {
urn
username
isNativeUser
info {
active
displayName
@ -90,6 +91,7 @@ query listUsers($input: ListUsersInput!) {
users {
urn
username
isNativeUser
info {
active
displayName
@ -124,3 +126,22 @@ mutation updateCorpUserProperties($urn: String!, $input: CorpUserUpdateInput!) {
urn
}
}
mutation createNativeUserInviteToken {
createNativeUserInviteToken {
inviteToken
}
}
query getNativeUserInviteToken {
getNativeUserInviteToken {
inviteToken
}
}
mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {
createNativeUserResetToken(input: $input) {
resetToken
}
}

View File

@ -1,14 +1,49 @@
# Adding Users to DataHub
Users can log into DataHub in 2 ways:
Users can log into DataHub in 3 ways:
1. Static credentials (Simplest)
2. Single Sign-On via [OpenID Connect](https://www.google.com/search?q=openid+connect&oq=openid+connect&aqs=chrome.0.0i131i433i512j0i512l4j69i60l2j69i61.1468j0j7&sourceid=chrome&ie=UTF-8) (For Production Use)
1. Invite users via the UI
2. Static credentials
3. Single Sign-On via [OpenID Connect](https://www.google.com/search?q=openid+connect&oq=openid+connect&aqs=chrome.0.0i131i433i512j0i512l4j69i60l2j69i61.1468j0j7&sourceid=chrome&ie=UTF-8) (For Production Use)
which can be both enabled simultaneously. Option 1 is useful for running proof-of-concept exercises, or just getting DataHub up & running quickly. Option 2 is highly recommended for deploying DataHub in production.
which can be enabled simultaneously. Options 1 and 2 are useful for running proof-of-concept exercises, or just getting DataHub up & running quickly. Option 3 is highly recommended for deploying DataHub in production.
# Method 1: Inviting users via the DataHub UI
# Method 1: Configuring static credentials
## Send prospective users an invite link
With the right permissions (`MANAGE_USER_CREDENTIALS`), you can invite new users to your deployed DataHub instance from the UI. It's as simple as sending a link!
First navigate, to the Users and Groups tab (under Access) on the Settings page. You'll then see an `Invite Users` button. Note that this will only be clickable
if you have the correct permissions.
![](../../imgs/invite-users-button.png)
If you click on this button, you'll see a pop-up where you can copy an invite link to send to users, or generate a fresh one.
![](../../imgs/invite-users-popup.png)
When a new user visits the link, they will be directed to a sign up screen. Note that if a new link has since been regenerated, the new user won't be able to sign up!
![](../../imgs/user-sign-up-screen.png)
## Reset password for native users
If a user forgets their password, an admin user with the `MANAGE_USER_CREDENTIALS` privilege can go to the Users and Groups tab and click on the respective user's
`Reset user password` button.
![](../../imgs/reset-user-password-button.png)
Similar to the invite link, you can generate a new reset link and send a link to that user which they can use to reset their credentials.
![](../../imgs/reset-user-password-popup.png)
When that user visits the link, they will be direct to a screen where they can reset their credentials. If the link is older than 24 hours or another link has since
been generated, they won't be able to reset their credentials!
![](../../imgs/reset-credentials-screen.png)
# Method 2: Configuring static credentials
## Create a user.props file
@ -134,8 +169,7 @@ and modify the `datahub-frontend-react` block to contain the extra volume mount.
datahub docker quickstart —quickstart-compose-file <your-modified-compose>.yml
```
# Method 2: Configuring SSO via OpenID Connect
# Method 3: Configuring SSO via OpenID Connect
Setting up SSO via OpenID Connect means that users will be able to login to DataHub via a central Identity Provider such as

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,32 @@
namespace com.linkedin.identity
import com.linkedin.common.CorpuserUrn
/**
* Corp user credentials
*/
@Aspect = {
"name": "corpUserCredentials"
}
@Aspect.EntityUrns = [ "com.linkedin.common.CorpuserUrn" ]
record CorpUserCredentials {
/**
* Salt used to hash password
*/
salt: string
/**
* Hashed password generated by concatenating salt and password, then hashing
*/
hashedPassword: string
/**
* Optional token needed to reset a user's password. Can only be set by the admin.
*/
passwordResetToken: optional string
/**
* When the password reset token expires.
*/
passwordResetTokenExpirationTimeMillis: optional long
}

View File

@ -0,0 +1,16 @@
namespace com.linkedin.identity
import com.linkedin.common.Urn
/**
* Aspect used to store the token needed to invite native DataHub users
*/
@Aspect = {
"name": "inviteToken"
}
record InviteToken {
/**
* The encrypted invite token.
*/
token: string
}

View File

@ -0,0 +1,14 @@
namespace com.linkedin.metadata.key
/**
* Key for an InviteToken.
*/
@Aspect = {
"name": "inviteTokenKey"
}
record InviteTokenKey {
/**
* A unique id for the invite token.
*/
id: string
}

View File

@ -88,6 +88,7 @@ entities:
- groupMembership
- globalTags
- status
- corpUserCredentials
- name: corpGroup
doc: CorpGroup represents an identity of a group of users in the enterprise.
keyAspect: corpGroupKey
@ -232,4 +233,9 @@ entities:
aspects:
- dataHubUpgradeRequest
- dataHubUpgradeResult
- name: inviteToken
category: core
keyAspect: inviteTokenKey
aspects:
- inviteToken
events:

View File

@ -0,0 +1,295 @@
package com.datahub.authentication.user;
import com.datahub.authentication.Authentication;
import com.linkedin.common.AuditStamp;
import com.linkedin.common.urn.Urn;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType;
import com.linkedin.identity.CorpUserCredentials;
import com.linkedin.identity.CorpUserInfo;
import com.linkedin.identity.CorpUserStatus;
import com.linkedin.identity.InviteToken;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.secret.SecretService;
import com.linkedin.metadata.utils.GenericRecordUtils;
import com.linkedin.mxe.MetadataChangeProposal;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import lombok.extern.slf4j.Slf4j;
import static com.linkedin.metadata.Constants.*;
/**
* Service responsible for creating, updating and authenticating native DataHub users.
*/
@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";
private static final long ONE_DAY_MILLIS = TimeUnit.DAYS.toMillis(1);
private final EntityService _entityService;
private final EntityClient _entityClient;
private final SecretService _secretService;
private final SecureRandom _secureRandom;
private final MessageDigest _messageDigest;
public NativeUserService(@Nonnull EntityService entityService, @Nonnull EntityClient entityClient, @Nonnull SecretService secretService)
throws Exception {
Objects.requireNonNull(entityService, "entityService must not be null!");
Objects.requireNonNull(entityClient, "entityClient must not be null!");
Objects.requireNonNull(secretService, "secretService must not be null!");
_entityService = entityService;
_entityClient = entityClient;
_secretService = secretService;
_secureRandom = new SecureRandom();
_messageDigest = MessageDigest.getInstance(HASHING_ALGORITHM);
}
public void createNativeUser(@Nonnull String userUrnString, @Nonnull String fullName, @Nonnull String email,
@Nonnull String title, @Nonnull String password, @Nonnull String inviteToken, 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!");
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!");
}
Urn userUrn = Urn.createFromString(userUrnString);
if (_entityService.exists(userUrn)) {
throw new RuntimeException("This user already exists! Cannot create a new user.");
}
updateCorpUserInfo(userUrn, fullName, email, title, authentication);
updateCorpUserStatus(userUrn, authentication);
updateCorpUserCredentials(userUrn, password, authentication);
}
void updateCorpUserInfo(@Nonnull Urn userUrn, @Nonnull String fullName, @Nonnull String email, @Nonnull String title,
Authentication authentication) throws Exception {
// Construct corpUserInfo
final CorpUserInfo corpUserInfo = new CorpUserInfo();
corpUserInfo.setFullName(fullName);
corpUserInfo.setDisplayName(fullName);
corpUserInfo.setEmail(email);
corpUserInfo.setTitle(title);
corpUserInfo.setActive(true);
// Ingest corpUserInfo MCP
final MetadataChangeProposal corpUserInfoProposal = new MetadataChangeProposal();
corpUserInfoProposal.setEntityType(CORP_USER_ENTITY_NAME);
corpUserInfoProposal.setEntityUrn(userUrn);
corpUserInfoProposal.setAspectName(CORP_USER_INFO_ASPECT_NAME);
corpUserInfoProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserInfo));
corpUserInfoProposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(corpUserInfoProposal, authentication);
}
void updateCorpUserStatus(@Nonnull Urn userUrn, Authentication authentication) throws Exception {
// Construct corpUserStatus
CorpUserStatus corpUserStatus = new CorpUserStatus();
corpUserStatus.setStatus(CORP_USER_STATUS_ACTIVE);
corpUserStatus.setLastModified(
new AuditStamp().setActor(Urn.createFromString(SYSTEM_ACTOR)).setTime(System.currentTimeMillis()));
// Ingest corpUserStatus MCP
final MetadataChangeProposal corpUserStatusProposal = new MetadataChangeProposal();
corpUserStatusProposal.setEntityType(CORP_USER_ENTITY_NAME);
corpUserStatusProposal.setEntityUrn(userUrn);
corpUserStatusProposal.setAspectName(CORP_USER_STATUS_ASPECT_NAME);
corpUserStatusProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserStatus));
corpUserStatusProposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(corpUserStatusProposal, authentication);
}
void updateCorpUserCredentials(@Nonnull Urn userUrn, @Nonnull String password,
Authentication authentication) throws Exception {
// Construct corpUserCredentials
CorpUserCredentials corpUserCredentials = new CorpUserCredentials();
final byte[] salt = getRandomBytes(SALT_TOKEN_LENGTH);
String encryptedSalt = _secretService.encrypt(Base64.getEncoder().encodeToString(salt));
corpUserCredentials.setSalt(encryptedSalt);
String hashedPassword = getHashedPassword(salt, password);
corpUserCredentials.setHashedPassword(hashedPassword);
// Ingest corpUserCredentials MCP
final MetadataChangeProposal corpUserCredentialsProposal = new MetadataChangeProposal();
corpUserCredentialsProposal.setEntityType(CORP_USER_ENTITY_NAME);
corpUserCredentialsProposal.setEntityUrn(userUrn);
corpUserCredentialsProposal.setAspectName(CORP_USER_CREDENTIALS_ASPECT_NAME);
corpUserCredentialsProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserCredentials));
corpUserCredentialsProposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(corpUserCredentialsProposal, authentication);
}
public String generateNativeUserInviteToken(Authentication authentication) throws Exception {
// Construct inviteToken
InviteToken inviteToken = new InviteToken();
String token = generateRandomLowercaseToken(INVITE_TOKEN_LENGTH);
inviteToken.setToken(_secretService.encrypt(token));
// Ingest corpUserCredentials MCP
final MetadataChangeProposal inviteTokenProposal = new MetadataChangeProposal();
inviteTokenProposal.setEntityType(INVITE_TOKEN_ENTITY_NAME);
inviteTokenProposal.setEntityUrn(Urn.createFromString(GLOBAL_INVITE_TOKEN));
inviteTokenProposal.setAspectName(INVITE_TOKEN_ASPECT_NAME);
inviteTokenProposal.setAspect(GenericRecordUtils.serializeAspect(inviteToken));
inviteTokenProposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(inviteTokenProposal, authentication);
return token;
}
public String getNativeUserInviteToken(Authentication authentication) throws Exception {
InviteToken inviteToken = (InviteToken) _entityService.getLatestAspect(Urn.createFromString(GLOBAL_INVITE_TOKEN),
INVITE_TOKEN_ASPECT_NAME);
if (inviteToken == null || !inviteToken.hasToken()) {
return generateNativeUserInviteToken(authentication);
}
return _secretService.decrypt(inviteToken.getToken());
}
public String generateNativeUserPasswordResetToken(@Nonnull String userUrnString,
Authentication authentication) throws Exception {
Objects.requireNonNull(userUrnString, "userUrnString must not be null!");
Urn userUrn = Urn.createFromString(userUrnString);
CorpUserCredentials corpUserCredentials =
(CorpUserCredentials) _entityService.getLatestAspect(userUrn, CORP_USER_CREDENTIALS_ASPECT_NAME);
if (corpUserCredentials == null || !corpUserCredentials.hasSalt() || !corpUserCredentials.hasHashedPassword()) {
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);
corpUserCredentials.setPasswordResetToken(_secretService.encrypt(passwordResetToken));
long expirationTime = Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli();
corpUserCredentials.setPasswordResetTokenExpirationTimeMillis(expirationTime);
// Ingest CorpUserCredentials MCP
final MetadataChangeProposal corpUserCredentialsProposal = new MetadataChangeProposal();
corpUserCredentialsProposal.setEntityType(CORP_USER_ENTITY_NAME);
corpUserCredentialsProposal.setEntityUrn(userUrn);
corpUserCredentialsProposal.setAspectName(CORP_USER_CREDENTIALS_ASPECT_NAME);
corpUserCredentialsProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserCredentials));
corpUserCredentialsProposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(corpUserCredentialsProposal, authentication);
return passwordResetToken;
}
public void resetCorpUserCredentials(@Nonnull String userUrnString, @Nonnull String password,
@Nonnull String resetToken, Authentication authentication) throws Exception {
Objects.requireNonNull(userUrnString, "userUrnString must not be null!");
Objects.requireNonNull(password, "password must not be null!");
Objects.requireNonNull(resetToken, "resetToken must not be null!");
Urn userUrn = Urn.createFromString(userUrnString);
CorpUserCredentials corpUserCredentials =
(CorpUserCredentials) _entityService.getLatestAspect(userUrn, CORP_USER_CREDENTIALS_ASPECT_NAME);
if (corpUserCredentials == null || !corpUserCredentials.hasSalt() || !corpUserCredentials.hasHashedPassword()) {
throw new RuntimeException("User does not exist!");
}
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)) {
throw new RuntimeException("Invalid reset token. Please ask your administrator to send you an updated link!");
}
long currentTimeMillis = Instant.now().toEpochMilli();
if (currentTimeMillis > corpUserCredentials.getPasswordResetTokenExpirationTimeMillis()) {
throw new RuntimeException("Reset token has expired! Please ask your administrator to create a new one");
}
// Construct corpUserCredentials
final byte[] salt = getRandomBytes(SALT_TOKEN_LENGTH);
String encryptedSalt = _secretService.encrypt(Base64.getEncoder().encodeToString(salt));
corpUserCredentials.setSalt(encryptedSalt);
String hashedPassword = getHashedPassword(salt, password);
corpUserCredentials.setHashedPassword(hashedPassword);
// Ingest corpUserCredentials MCP
final MetadataChangeProposal corpUserCredentialsProposal = new MetadataChangeProposal();
corpUserCredentialsProposal.setEntityType(CORP_USER_ENTITY_NAME);
corpUserCredentialsProposal.setEntityUrn(userUrn);
corpUserCredentialsProposal.setAspectName(CORP_USER_CREDENTIALS_ASPECT_NAME);
corpUserCredentialsProposal.setAspect(GenericRecordUtils.serializeAspect(corpUserCredentials));
corpUserCredentialsProposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(corpUserCredentialsProposal, authentication);
}
byte[] getRandomBytes(int length) {
byte[] randomBytes = new byte[length];
_secureRandom.nextBytes(randomBytes);
return randomBytes;
}
String generateRandomLowercaseToken(int length) {
return _secureRandom.ints(length, LOWERCASE_ASCII_START, LOWERCASE_ASCII_END + 1)
.mapToObj(i -> String.valueOf((char) i))
.collect(Collectors.joining());
}
byte[] saltPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException {
byte[] passwordBytes = password.getBytes();
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byteArrayOutputStream.write(salt);
byteArrayOutputStream.write(passwordBytes);
return byteArrayOutputStream.toByteArray();
}
public String getHashedPassword(@Nonnull byte[] salt, @Nonnull String password) throws IOException {
byte[] saltedPassword = saltPassword(salt, password);
byte[] hashedPassword = _messageDigest.digest(saltedPassword);
return Base64.getEncoder().encodeToString(hashedPassword);
}
public boolean doesPasswordMatch(@Nonnull String userUrnString, @Nonnull String password) throws Exception {
Objects.requireNonNull(userUrnString, "userUrnSting must not be null!");
Objects.requireNonNull(password, "Password must not be null!");
Urn userUrn = Urn.createFromString(userUrnString);
CorpUserCredentials corpUserCredentials =
(CorpUserCredentials) _entityService.getLatestAspect(userUrn, CORP_USER_CREDENTIALS_ASPECT_NAME);
if (corpUserCredentials == null || !corpUserCredentials.hasSalt() || !corpUserCredentials.hasHashedPassword()) {
return false;
}
String decryptedSalt = _secretService.decrypt(corpUserCredentials.getSalt());
byte[] salt = Base64.getDecoder().decode(decryptedSalt);
String storedHashedPassword = corpUserCredentials.getHashedPassword();
String hashedPassword = getHashedPassword(salt, password);
return storedHashedPassword.equals(hashedPassword);
}
}

View File

@ -0,0 +1,289 @@
package com.datahub.authentication.user;
import com.datahub.authentication.Actor;
import com.datahub.authentication.ActorType;
import com.datahub.authentication.Authentication;
import com.linkedin.common.urn.CorpuserUrn;
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;
import java.util.concurrent.TimeUnit;
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 NativeUserServiceTest {
private static final String DATAHUB_SYSTEM_CLIENT_ID = "__datahub_system";
private static final String USER_URN_STRING = "urn:li:corpuser:test";
private static final String FULL_NAME = "MOCK NAME";
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 RESET_TOKEN = "inviteToken";
private static final String ENCRYPTED_RESET_TOKEN = "encryptedInviteToken";
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);
private static final Authentication SYSTEM_AUTHENTICATION =
new Authentication(new Actor(ActorType.USER, DATAHUB_SYSTEM_CLIENT_ID), "");
private EntityService _entityService;
private EntityClient _entityClient;
private SecretService _secretService;
private NativeUserService _nativeUserService;
@BeforeMethod
public void setupTest() throws Exception {
_entityService = mock(EntityService.class);
_entityClient = mock(EntityClient.class);
_secretService = mock(SecretService.class);
_nativeUserService = new NativeUserService(_entityService, _entityClient, _secretService);
}
@Test
public void testConstructor() throws Exception {
assertThrows(() -> new NativeUserService(null, _entityClient, _secretService));
assertThrows(() -> new NativeUserService(_entityService, null, _secretService));
assertThrows(() -> new NativeUserService(_entityService, _entityClient, null));
// Succeeds!
new NativeUserService(_entityService, _entityClient, _secretService);
}
@Test
public void testCreateNativeUserNullArguments() {
assertThrows(() -> _nativeUserService.createNativeUser(null, FULL_NAME, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
SYSTEM_AUTHENTICATION));
assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, null, EMAIL, TITLE, PASSWORD, INVITE_TOKEN,
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,
SYSTEM_AUTHENTICATION));
assertThrows(() -> _nativeUserService.createNativeUser(USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, 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);
}
@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);
}
@Test
public void testUpdateCorpUserInfoPasses() throws Exception {
_nativeUserService.updateCorpUserInfo(USER_URN, FULL_NAME, EMAIL, TITLE, SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testUpdateCorpUserStatusPasses() throws Exception {
_nativeUserService.updateCorpUserStatus(USER_URN, SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testUpdateCorpUserCredentialsPasses() throws Exception {
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT);
_nativeUserService.updateCorpUserCredentials(USER_URN, PASSWORD, SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testGenerateNativeUserInviteTokenPasses() throws Exception {
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN);
_nativeUserService.generateNativeUserInviteToken(SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testGetNativeUserInviteTokenInviteTokenDoesNotExistPasses() throws Exception {
// Nonexistent invite token
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(null);
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN);
_nativeUserService.getNativeUserInviteToken(SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testGetNativeUserInviteTokenPasses() throws Exception {
InviteToken mockInviteTokenAspect = mock(InviteToken.class);
when(_entityService.getLatestAspect(any(), eq(INVITE_TOKEN_ASPECT_NAME))).thenReturn(mockInviteTokenAspect);
when(_entityService.exists(any())).thenReturn(false);
when(mockInviteTokenAspect.hasToken()).thenReturn(true);
when(mockInviteTokenAspect.getToken()).thenReturn(ENCRYPTED_INVITE_TOKEN);
when(_secretService.decrypt(eq(ENCRYPTED_INVITE_TOKEN))).thenReturn(INVITE_TOKEN);
assertEquals(_nativeUserService.getNativeUserInviteToken(SYSTEM_AUTHENTICATION), INVITE_TOKEN);
}
@Test
public void testGenerateNativeUserResetTokenNullArguments() {
assertThrows(() -> _nativeUserService.generateNativeUserPasswordResetToken(null, SYSTEM_AUTHENTICATION));
}
@Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = "User does not exist or is a non-native user!")
public void testGenerateNativeUserResetTokenNotNativeUser() throws Exception {
// Nonexistent corpUserCredentials
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(null);
_nativeUserService.generateNativeUserPasswordResetToken(USER_URN_STRING, SYSTEM_AUTHENTICATION);
}
@Test
public void testGenerateNativeUserResetToken() throws Exception {
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
mockCorpUserCredentialsAspect);
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_INVITE_TOKEN);
_nativeUserService.generateNativeUserPasswordResetToken(USER_URN_STRING, SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testResetCorpUserCredentialsNullArguments() {
assertThrows(() -> _nativeUserService.resetCorpUserCredentials(null, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION));
assertThrows(
() -> _nativeUserService.resetCorpUserCredentials(USER_URN_STRING, null, RESET_TOKEN, SYSTEM_AUTHENTICATION));
assertThrows(
() -> _nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, null, SYSTEM_AUTHENTICATION));
}
@Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = "User has not generated a password reset token!")
public void testResetCorpUserCredentialsNoPasswordResetToken() throws Exception {
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
mockCorpUserCredentialsAspect);
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
// No password reset token
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(false);
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
}
@Test(expectedExceptions = RuntimeException.class,
expectedExceptionsMessageRegExp = "Invalid reset token. Please ask your administrator to send you an updated link!")
public void testResetCorpUserCredentialsBadResetToken() throws Exception {
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
mockCorpUserCredentialsAspect);
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(true);
when(mockCorpUserCredentialsAspect.getPasswordResetToken()).thenReturn(ENCRYPTED_RESET_TOKEN);
when(mockCorpUserCredentialsAspect.hasPasswordResetTokenExpirationTimeMillis()).thenReturn(true);
when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn(
Instant.now().toEpochMilli());
// Reset token won't match
when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn("badResetToken");
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
}
@Test(expectedExceptions = RuntimeException.class,
expectedExceptionsMessageRegExp = "Reset token has expired! Please ask your administrator to create a new one")
public void testResetCorpUserCredentialsExpiredResetToken() throws Exception {
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
mockCorpUserCredentialsAspect);
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(true);
when(mockCorpUserCredentialsAspect.getPasswordResetToken()).thenReturn(ENCRYPTED_RESET_TOKEN);
when(mockCorpUserCredentialsAspect.hasPasswordResetTokenExpirationTimeMillis()).thenReturn(true);
// Reset token expiration time will be before the system time when we run resetCorpUserCredentials
when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn(0L);
when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn(RESET_TOKEN);
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
}
@Test
public void testResetCorpUserCredentialsPasses() throws Exception {
CorpUserCredentials mockCorpUserCredentialsAspect = mock(CorpUserCredentials.class);
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(
mockCorpUserCredentialsAspect);
when(mockCorpUserCredentialsAspect.hasSalt()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasHashedPassword()).thenReturn(true);
when(mockCorpUserCredentialsAspect.hasPasswordResetToken()).thenReturn(true);
when(mockCorpUserCredentialsAspect.getPasswordResetToken()).thenReturn(ENCRYPTED_RESET_TOKEN);
when(mockCorpUserCredentialsAspect.hasPasswordResetTokenExpirationTimeMillis()).thenReturn(true);
when(mockCorpUserCredentialsAspect.getPasswordResetTokenExpirationTimeMillis()).thenReturn(
Instant.now().plusMillis(ONE_DAY_MILLIS).toEpochMilli());
when(_secretService.decrypt(eq(ENCRYPTED_RESET_TOKEN))).thenReturn(RESET_TOKEN);
when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT);
_nativeUserService.resetCorpUserCredentials(USER_URN_STRING, PASSWORD, RESET_TOKEN, SYSTEM_AUTHENTICATION);
verify(_entityClient).ingestProposal(any(), any());
}
@Test
public void testDoesPasswordMatchNullArguments() {
assertThrows(() -> _nativeUserService.doesPasswordMatch(null, PASSWORD));
assertThrows(() -> _nativeUserService.doesPasswordMatch(USER_URN_STRING, null));
}
@Test
public void testDoesPasswordMatchNoCorpUserCredentials() throws Exception {
when(_entityService.getLatestAspect(any(), eq(CORP_USER_CREDENTIALS_ASPECT_NAME))).thenReturn(null);
assertFalse(_nativeUserService.doesPasswordMatch(USER_URN_STRING, PASSWORD));
}
}

View File

@ -1,7 +1,8 @@
package com.datahub.authentication;
import com.datahub.authentication.token.TokenType;
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;
@ -25,6 +26,16 @@ public class AuthServiceController {
private static final String USER_ID_FIELD_NAME = "userId";
private static final String ACCESS_TOKEN_FIELD_NAME = "accessToken";
private static final String USER_URN_FIELD_NAME = "userUrn";
private static final String FULL_NAME_FIELD_NAME = "fullName";
private static final String EMAIL_FIELD_NAME = "email";
private static final String TITLE_FIELD_NAME = "title";
private static final String PASSWORD_FIELD_NAME = "password";
private static final String INVITE_TOKEN_FIELD_NAME = "inviteToken";
private static final String RESET_TOKEN_FIELD_NAME = "resetToken";
private static final String IS_NATIVE_USER_CREATED_FIELD_NAME = "isNativeUserCreated";
private static final String ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD_NAME = "areNativeUserCredentialsReset";
private static final String DOES_PASSWORD_MATCH_FIELD_NAME = "doesPasswordMatch";
@Inject
StatelessTokenService _statelessTokenService;
@ -35,13 +46,16 @@ public class AuthServiceController {
@Inject
ConfigurationProvider _configProvider;
@Inject
NativeUserService _nativeUserService;
/**
* 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.
*
* Example Request:
*
* POST /generateSessionToken -H "Authorization: Basic <system-client-id>:<system-client-secret>"
* POST /generateSessionTokenForUser -H "Authorization: Basic <system-client-id>:<system-client-secret>"
* {
* "userId": "datahub"
* }
@ -95,7 +109,192 @@ public class AuthServiceController {
});
}
// Currently only internal system is authorized to generate a token on behalf of a user!
/**
* Creates a native DataHub user using the provided full name, email and password. The provided invite token must
* be current otherwise a new user will not be created.
*
* Example Request:
*
* POST /signUp -H "Authorization: Basic <system-client-id>:<system-client-secret>"
* {
* "fullName": "Full Name"
* "userUrn": "urn:li:corpuser:test"
* "email": "email@test.com"
* "title": "Data Scientist"
* "password": "password123"
* "inviteToken": "abcd"
* }
*
* Example Response:
*
* {
* "isNativeUserCreated": true
* }
*/
@PostMapping(value = "/signUp", produces = "application/json;charset=utf-8")
CompletableFuture<ResponseEntity<String>> signUp(final HttpEntity<String> httpEntity) {
String jsonStr = httpEntity.getBody();
ObjectMapper mapper = new ObjectMapper();
JsonNode bodyJson;
try {
bodyJson = mapper.readTree(jsonStr);
} catch (JsonProcessingException e) {
log.error(String.format("Failed to parse json while attempting to create native user %s", jsonStr));
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
if (bodyJson == null) {
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
/*
* Extract username and password field
*/
JsonNode userUrn = bodyJson.get(USER_URN_FIELD_NAME);
JsonNode fullName = bodyJson.get(FULL_NAME_FIELD_NAME);
JsonNode email = bodyJson.get(EMAIL_FIELD_NAME);
JsonNode title = bodyJson.get(TITLE_FIELD_NAME);
JsonNode password = bodyJson.get(PASSWORD_FIELD_NAME);
JsonNode inviteToken = bodyJson.get(INVITE_TOKEN_FIELD_NAME);
if (fullName == null || userUrn == null || email == null || title == null || password == null
|| inviteToken == null) {
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
String userUrnString = userUrn.asText();
String fullNameString = fullName.asText();
String emailString = email.asText();
String titleString = title.asText();
String passwordString = password.asText();
String inviteTokenString = inviteToken.asText();
log.debug(String.format("Attempting to create credentials for native user %s", userUrnString));
return CompletableFuture.supplyAsync(() -> {
try {
_nativeUserService.createNativeUser(userUrnString, fullNameString, emailString, titleString, passwordString,
inviteTokenString, AuthenticationContext.getAuthentication());
String response = buildSignUpResponse();
return new ResponseEntity<>(response, HttpStatus.OK);
} catch (Exception e) {
log.error(String.format("Failed to create credentials for native user %s", userUrnString), e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
});
}
/**
* Resets the credentials for a native DataHub user using the provided email and new password. The provided reset
* token must be current otherwise the credentials will not be updated
*
* Example Request:
*
* POST /resetNativeUserCredentials -H "Authorization: Basic <system-client-id>:<system-client-secret>"
* {
* "userUrn": "urn:li:corpuser:test"
* "password": "password123"
* "resetToken": "abcd"
* }
*
* Example Response:
*
* {
* "areNativeUserCredentialsReset": true
* }
*/
@PostMapping(value = "/resetNativeUserCredentials", produces = "application/json;charset=utf-8")
CompletableFuture<ResponseEntity<String>> resetNativeUserCredentials(final HttpEntity<String> httpEntity) {
String jsonStr = httpEntity.getBody();
ObjectMapper mapper = new ObjectMapper();
JsonNode bodyJson;
try {
bodyJson = mapper.readTree(jsonStr);
} catch (JsonProcessingException e) {
log.error(String.format("Failed to parse json while attempting to create native user %s", jsonStr));
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
if (bodyJson == null) {
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
/*
* Extract username and password field
*/
JsonNode userUrn = bodyJson.get(USER_URN_FIELD_NAME);
JsonNode password = bodyJson.get(PASSWORD_FIELD_NAME);
JsonNode resetToken = bodyJson.get(RESET_TOKEN_FIELD_NAME);
if (userUrn == null || password == null || resetToken == null) {
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
String userUrnString = userUrn.asText();
String passwordString = password.asText();
String resetTokenString = resetToken.asText();
log.debug(String.format("Attempting to reset credentials for native user %s", userUrnString));
return CompletableFuture.supplyAsync(() -> {
try {
_nativeUserService.resetCorpUserCredentials(userUrnString, passwordString, resetTokenString,
AuthenticationContext.getAuthentication());
String response = buildResetNativeUserCredentialsResponse();
return new ResponseEntity<>(response, HttpStatus.OK);
} catch (Exception e) {
log.error(String.format("Failed to reset credentials for native user %s", userUrnString), e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
});
}
/**
* Verifies the credentials for a native DataHub user.
*
* Example Request:
*
* POST /verifyNativeUserCredentials -H "Authorization: Basic <system-client-id>:<system-client-secret>"
* {
* "userUrn": "urn:li:corpuser:test"
* "password": "password123"
* }
*
* Example Response:
*
* {
* "passwordMatches": true
* }
*/
@PostMapping(value = "/verifyNativeUserCredentials", produces = "application/json;charset=utf-8")
CompletableFuture<ResponseEntity<String>> verifyNativeUserCredentials(final HttpEntity<String> httpEntity) {
String jsonStr = httpEntity.getBody();
ObjectMapper mapper = new ObjectMapper();
JsonNode bodyJson;
try {
bodyJson = mapper.readTree(jsonStr);
} catch (JsonProcessingException e) {
log.error(String.format("Failed to parse json while attempting to verify native user password %s", jsonStr));
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
if (bodyJson == null) {
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
/*
* Extract username and password field
*/
JsonNode userUrn = bodyJson.get(USER_URN_FIELD_NAME);
JsonNode password = bodyJson.get(PASSWORD_FIELD_NAME);
if (userUrn == null || password == null) {
return CompletableFuture.completedFuture(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
}
String userUrnString = userUrn.asText();
String passwordString = password.asText();
log.debug(String.format("Attempting to verify credentials for native user %s", userUrnString));
return CompletableFuture.supplyAsync(() -> {
try {
boolean doesPasswordMatch = _nativeUserService.doesPasswordMatch(userUrnString, passwordString);
String response = buildVerifyNativeUserPasswordResponse(doesPasswordMatch);
return new ResponseEntity<>(response, HttpStatus.OK);
} catch (Exception e) {
log.error(String.format("Failed to verify credentials for native user %s", userUrnString), e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
});
}
// Currently, only internal system is authorized to generate a token on behalf of a user!
private boolean isAuthorizedToGenerateSessionToken(final String actorId) {
// Verify that the actor is an internal system caller.
final String systemClientId = _systemAuthentication.getActor().getId();
@ -107,4 +306,22 @@ public class AuthServiceController {
json.put(ACCESS_TOKEN_FIELD_NAME, token);
return json.toString();
}
private String buildSignUpResponse() {
JSONObject json = new JSONObject();
json.put(IS_NATIVE_USER_CREATED_FIELD_NAME, true);
return json.toString();
}
private String buildResetNativeUserCredentialsResponse() {
JSONObject json = new JSONObject();
json.put(ARE_NATIVE_USER_CREDENTIALS_RESET_FIELD_NAME, true);
return json.toString();
}
private String buildVerifyNativeUserPasswordResponse(final boolean doesPasswordMatch) {
JSONObject json = new JSONObject();
json.put(DOES_PASSWORD_MATCH_FIELD_NAME, doesPasswordMatch);
return json.toString();
}
}

View File

@ -0,0 +1,40 @@
package com.linkedin.gms.factory.auth;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.entity.client.JavaEntityClient;
import com.linkedin.gms.factory.spring.YamlPropertySourceFactory;
import com.linkedin.metadata.entity.EntityService;
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 NativeUserServiceFactory {
@Autowired
@Qualifier("entityService")
private EntityService _entityService;
@Autowired
@Qualifier("javaEntityClient")
private JavaEntityClient _javaEntityClient;
@Autowired
@Qualifier("dataHubSecretService")
private SecretService _secretService;
@Bean(name = "nativeUserService")
@Scope("singleton")
@Nonnull
protected NativeUserService getInstance() throws Exception {
return new NativeUserService(this._entityService, this._javaEntityClient, this._secretService);
}
}

View File

@ -1,6 +1,7 @@
package com.linkedin.gms.factory.graphql;
import com.datahub.authentication.token.StatefulTokenService;
import com.datahub.authentication.user.NativeUserService;
import com.linkedin.datahub.graphql.GmsGraphQLEngine;
import com.linkedin.datahub.graphql.GraphQLEngine;
import com.linkedin.datahub.graphql.analytics.service.AnalyticsService;
@ -97,6 +98,10 @@ public class GraphQLEngineFactory {
@Qualifier("timelineService")
private TimelineService _timelineService;
@Autowired
@Qualifier("nativeUserService")
private NativeUserService _nativeUserService;
@Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED
private Boolean isAnalyticsEnabled;
@ -115,6 +120,7 @@ public class GraphQLEngineFactory {
_timeseriesAspectService,
_entityRegistry,
_secretService,
_nativeUserService,
_configProvider.getIngestion(),
_configProvider.getAuthentication(),
_configProvider.getAuthorization(),
@ -138,6 +144,7 @@ public class GraphQLEngineFactory {
_timeseriesAspectService,
_entityRegistry,
_secretService,
_nativeUserService,
_configProvider.getIngestion(),
_configProvider.getAuthentication(),
_configProvider.getAuthorization(),

View File

@ -20,7 +20,8 @@
"MANAGE_ACCESS_TOKENS",
"MANAGE_DOMAINS",
"MANAGE_TESTS",
"MANAGE_GLOSSARIES"
"MANAGE_GLOSSARIES",
"MANAGE_USER_CREDENTIALS"
],
"displayName":"Root User - All Platform Privileges",
"description":"Grants full platform privileges to root datahub super user.",

View File

@ -15,6 +15,8 @@ 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
*/
@ -45,6 +47,8 @@ public class Constants {
public static final String DATA_PLATFORM_INSTANCE_ENTITY_NAME = "dataPlatformInstance";
public static final String ACCESS_TOKEN_ENTITY_NAME = "dataHubAccessToken";
public static final String DATA_HUB_UPGRADE_ENTITY_NAME = "dataHubUpgrade";
public static final String INVITE_TOKEN_ENTITY_NAME = "inviteToken";
/**
* Aspects
@ -68,6 +72,7 @@ public class Constants {
public static final String CORP_USER_EDITABLE_INFO_ASPECT_NAME = "corpUserEditableInfo";
public static final String CORP_USER_INFO_ASPECT_NAME = "corpUserInfo";
public static final String CORP_USER_STATUS_ASPECT_NAME = "corpUserStatus";
public static final String CORP_USER_CREDENTIALS_ASPECT_NAME = "corpUserCredentials";
// Group
public static final String CORP_GROUP_KEY_ASPECT_NAME = "corpGroupKey";
@ -217,6 +222,10 @@ public class Constants {
public static final String DATA_HUB_UPGRADE_RESULT_ASPECT_NAME = "dataHubUpgradeResult";
// Invite Token
public static final String INVITE_TOKEN_ASPECT_NAME = "inviteToken";
// acryl-main only
public static final String CHANGE_EVENT_PLATFORM_EVENT_NAME = "entityChangeEvent";
/**

View File

@ -75,6 +75,10 @@ public class PoliciesConfig {
"Manage Glossaries",
"Create, edit, and remove Glossary Entities");
public static final Privilege MANAGE_USER_CREDENTIALS_PRIVILEGE =
Privilege.of("MANAGE_USER_CREDENTIALS", "Manage User Credentials",
"Manage credentials for native DataHub users, including inviting new users and resetting passwords");
public static final List<Privilege> PLATFORM_PRIVILEGES = ImmutableList.of(
MANAGE_POLICIES_PRIVILEGE,
MANAGE_USERS_AND_GROUPS_PRIVILEGE,
@ -85,7 +89,8 @@ public class PoliciesConfig {
GENERATE_PERSONAL_ACCESS_TOKENS_PRIVILEGE,
MANAGE_ACCESS_TOKENS,
MANAGE_TESTS_PRIVILEGE,
MANAGE_GLOSSARIES_PRIVILEGE
MANAGE_GLOSSARIES_PRIVILEGE,
MANAGE_USER_CREDENTIALS_PRIVILEGE
);
// Resource Privileges //

View File

@ -865,7 +865,8 @@ def test_frontend_me_query(frontend_session):
viewAnalytics
managePolicies
manageIdentities
generatePersonalAccessTokens
manageUserCredentials
generatePersonalAccessTokens
}\n
}\n
}"""
@ -880,6 +881,7 @@ def test_frontend_me_query(frontend_session):
assert res_data["data"]["me"]["corpUser"]["urn"] == "urn:li:corpuser:datahub"
assert res_data["data"]["me"]["platformPrivileges"]["viewAnalytics"] is True
assert res_data["data"]["me"]["platformPrivileges"]["managePolicies"] is True
assert res_data["data"]["me"]["platformPrivileges"]["manageUserCredentials"] is True
assert res_data["data"]["me"]["platformPrivileges"]["manageIdentities"] is True
assert (
res_data["data"]["me"]["platformPrivileges"]["generatePersonalAccessTokens"]
@ -1445,3 +1447,159 @@ def test_generate_personal_access_token(frontend_session):
assert res_data
assert "errors" in res_data # Assert the request fails
@pytest.mark.dependency(depends=["test_healthchecks", "test_run_ingestion"])
def test_native_user_endpoints(frontend_session):
# Sign up tests
# Test getting the invite token
get_invite_token_json = {
"query": """query getNativeUserInviteToken {\n
getNativeUserInviteToken{\n
inviteToken\n
}\n
}"""
}
get_invite_token_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/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 get_invite_token_res_data
# Pass the invite token when creating the user
sign_up_json = {
"fullName": "Test User",
"email": "test@email.com",
"password": "password",
"title": "Date Engineer",
"inviteToken": invite_token
}
sign_up_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/signUp", json=sign_up_json)
assert sign_up_response
assert "error" not in sign_up_response
# Creating the same user again fails
same_user_sign_up_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/signUp", json=sign_up_json)
assert not same_user_sign_up_response
# Test that a bad invite token leads to failed sign up
bad_sign_up_json = {
"fullName": "Test2 User",
"email": "test2@email.com",
"password": "password",
"title": "Date Engineer",
"inviteToken": "invite_token"
}
bad_sign_up_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/signUp", json=bad_sign_up_json)
assert not bad_sign_up_response
frontend_session.cookies.clear()
# Reset credentials tests
# Log in as root again
headers = {
"Content-Type": "application/json",
}
root_login_data = '{"username":"datahub", "password":"datahub"}'
frontend_session.post(f"{FRONTEND_ENDPOINT}/logIn", headers=headers, data=root_login_data)
# Test creating the password reset token
create_reset_token_json = {
"query": """mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {\n
createNativeUserResetToken(input: $input) {\n
resetToken\n
}\n
}""",
"variables": {
"input": {
"userUrn": "urn:li:corpuser:test@email.com"
}
},
}
create_reset_token_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=create_reset_token_json)
create_reset_token_response.raise_for_status()
create_reset_token_res_data = create_reset_token_response.json()
assert create_reset_token_res_data
assert create_reset_token_res_data["data"]
reset_token = create_reset_token_res_data["data"]["createNativeUserResetToken"]["resetToken"]
assert reset_token is not None
assert "error" not in create_reset_token_res_data
# Pass the reset token when resetting credentials
reset_credentials_json = {
"email": "test@email.com",
"password": "password",
"resetToken": reset_token
}
reset_credentials_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/resetNativeUserCredentials", json=reset_credentials_json)
assert reset_credentials_response
assert "error" not in reset_credentials_response
# Test that a bad reset token leads to failed response
bad_user_reset_credentials_json = {
"email": "test@email.com",
"password": "password",
"resetToken": "reset_token"
}
bad_reset_credentials_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/resetNativeUserCredentials", json=bad_user_reset_credentials_json)
assert not bad_reset_credentials_response
# Test that only a native user can reset their password
jaas_user_reset_credentials_json = {
"email": "datahub",
"password": "password",
"resetToken": reset_token
}
jaas_user_reset_credentials_response = frontend_session.post(f"{FRONTEND_ENDPOINT}/resetNativeUserCredentials", json=jaas_user_reset_credentials_json)
assert not jaas_user_reset_credentials_response
# Tests that unauthenticated users can't invite users or send reset password links
native_user_frontend_session = requests.Session()
native_user_login_data = '{"username":"test@email.com", "password":"password"}'
native_user_frontend_session.post(f"{FRONTEND_ENDPOINT}/logIn", headers=headers, data=native_user_login_data)
unauthenticated_get_invite_token_response = native_user_frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=get_invite_token_json)
unauthenticated_get_invite_token_response.raise_for_status()
unauthenticated_get_invite_token_res_data = unauthenticated_get_invite_token_response.json()
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
unauthenticated_create_reset_token_json = {
"query": """mutation createNativeUserResetToken($input: CreateNativeUserResetTokenInput!) {\n
createNativeUserResetToken(input: $input) {\n
resetToken\n
}\n
}""",
"variables": {
"input": {
"userUrn": "urn:li:corpuser:test@email.com"
}
},
}
unauthenticated_create_reset_token_response = native_user_frontend_session.post(f"{FRONTEND_ENDPOINT}/api/v2/graphql", json=unauthenticated_create_reset_token_json)
unauthenticated_create_reset_token_response.raise_for_status()
unauthenticated_create_reset_token_res_data = unauthenticated_create_reset_token_response.json()
assert unauthenticated_create_reset_token_res_data
assert "errors" in unauthenticated_create_reset_token_res_data
assert unauthenticated_create_reset_token_res_data["data"]
assert unauthenticated_create_reset_token_res_data["data"]["createNativeUserResetToken"] is None