diff --git a/src/main/java/io/supertokens/storage/postgresql/LockFailure.java b/src/main/java/io/supertokens/storage/postgresql/LockFailure.java new file mode 100644 index 00000000..70cfcd5f --- /dev/null +++ b/src/main/java/io/supertokens/storage/postgresql/LockFailure.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.postgresql; + +public class LockFailure extends Exception { + public LockFailure() { + super("Failed to acquire advisory lock"); + } + + public LockFailure(String message) { + super(message); + } +} diff --git a/src/main/java/io/supertokens/storage/postgresql/Start.java b/src/main/java/io/supertokens/storage/postgresql/Start.java index 28cea0ee..970e471e 100644 --- a/src/main/java/io/supertokens/storage/postgresql/Start.java +++ b/src/main/java/io/supertokens/storage/postgresql/Start.java @@ -50,11 +50,24 @@ import io.supertokens.pluginInterface.RECIPE_ID; import io.supertokens.pluginInterface.STORAGE_TYPE; import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.authRecipe.CanBecomePrimaryResult; import io.supertokens.pluginInterface.authRecipe.LoginMethod; +import io.supertokens.pluginInterface.authRecipe.exceptions.AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.AnotherPrimaryUserWithEmailAlreadyExistsException; +import io.supertokens.pluginInterface.authRecipe.exceptions.AnotherPrimaryUserWithPhoneNumberAlreadyExistsException; +import io.supertokens.pluginInterface.authRecipe.exceptions.AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException; +import io.supertokens.pluginInterface.authRecipe.exceptions.CannotBecomePrimarySinceRecipeUserIdAlreadyLinkedWithPrimaryUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.CannotLinkSinceRecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.EmailChangeNotAllowedException; +import io.supertokens.pluginInterface.authRecipe.exceptions.InputUserIdIsNotAPrimaryUserException; +import io.supertokens.pluginInterface.authRecipe.exceptions.PhoneNumberChangeNotAllowedException; +import io.supertokens.pluginInterface.authRecipe.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.authRecipe.sqlStorage.AuthRecipeSQLStorage; import io.supertokens.pluginInterface.bulkimport.BulkImportStorage; import io.supertokens.pluginInterface.bulkimport.BulkImportUser; +import io.supertokens.pluginInterface.bulkimport.PrimaryUser; import io.supertokens.pluginInterface.bulkimport.exceptions.BulkImportBatchInsertException; import io.supertokens.pluginInterface.bulkimport.exceptions.BulkImportTransactionRolledBackException; import io.supertokens.pluginInterface.bulkimport.sqlStorage.BulkImportSQLStorage; @@ -68,7 +81,6 @@ import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateEmailException; import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicatePasswordResetTokenException; import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateUserIdException; -import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.emailpassword.sqlStorage.EmailPasswordSQLStorage; import io.supertokens.pluginInterface.emailverification.EmailVerificationStorage; import io.supertokens.pluginInterface.emailverification.EmailVerificationTokenInfo; @@ -142,7 +154,6 @@ import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential; import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateOptionsIdException; import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateRecoverAccountTokenException; -import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserEmailException; import io.supertokens.pluginInterface.webauthn.exceptions.WebauthNCredentialNotExistsException; import io.supertokens.pluginInterface.webauthn.exceptions.WebauthNOptionsNotExistsException; import io.supertokens.pluginInterface.webauthn.slqStorage.WebAuthNSQLStorage; @@ -151,6 +162,7 @@ import io.supertokens.storage.postgresql.config.Config; import io.supertokens.storage.postgresql.config.PostgreSQLConfig; import io.supertokens.storage.postgresql.output.Logging; +import io.supertokens.storage.postgresql.queries.AccountInfoQueries; import io.supertokens.storage.postgresql.queries.ActiveUsersQueries; import io.supertokens.storage.postgresql.queries.BulkImportQueries; import io.supertokens.storage.postgresql.queries.DashboardQueries; @@ -342,7 +354,7 @@ public void initStorage(boolean shouldWait, List tenantIdentif @Override public T startTransaction(TransactionLogic logic) throws StorageTransactionLogicException, StorageQueryException { - return startTransaction(logic, TransactionIsolationLevel.SERIALIZABLE); + return startTransaction(logic, TransactionIsolationLevel.READ_COMMITTED); } @Override @@ -384,6 +396,7 @@ public T startTransaction(TransactionLogic logic, TransactionIsolationLev // We could get here if the new logic hits a false negative, // e.g., in case someone renamed constraints/tables boolean isDeadlockException = actualException instanceof SQLTransactionRollbackException + || actualException instanceof LockFailure || exceptionMessage.toLowerCase().contains("concurrent update") || exceptionMessage.toLowerCase().contains("concurrent delete") || exceptionMessage.toLowerCase().contains("the transaction might succeed if retried") || @@ -1112,6 +1125,8 @@ public AuthRecipeUserInfo signUp(TenantIdentifier tenantIdentifier, String id, S if (isUniqueConstraintError(serverMessage, config.getEmailPasswordUserToTenantTable(), "email")) { throw new DuplicateEmailException(); + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserTenantsTable())) { + throw new DuplicateEmailException(); } else if (isPrimaryKeyError(serverMessage, config.getEmailPasswordUsersTable()) || isPrimaryKeyError(serverMessage, config.getUsersTable()) || isPrimaryKeyError(serverMessage, config.getEmailPasswordUserToTenantTable()) @@ -1136,7 +1151,7 @@ public void signUpMultipleViaBulkImport_Transaction(TransactionConnection connec throws StorageQueryException, StorageTransactionLogicException { try { Connection sqlConnection = (Connection) connection.getConnection(); - EmailPasswordQueries.signUpMultipleForBulkImport_Transaction(this, sqlConnection, users); + EmailPasswordQueries.importUsers_Transaction(this, sqlConnection, users); } catch (StorageQueryException | StorageTransactionLogicException e) { Throwable actual = e.getCause(); if (actual instanceof BatchUpdateException batchUpdateException) { @@ -1154,6 +1169,20 @@ public void signUpMultipleViaBulkImport_Transaction(TransactionConnection connec errorByPosition.put(users.get(position).userId, new DuplicateEmailException()); + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserTenantsTable())) { + EmailPasswordImportUser user = null; + for (var u : users) { + if (position < u.recipeUserTenantIds.size()) { + user = u; + break; + } + position -= u.recipeUserTenantIds.size(); + } + assert user != null; + errorByPosition.put(user.userId, new DuplicateEmailException()); + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserAccountInfosTable())) { + errorByPosition.put(users.get(position).userId, + new IllegalStateException("should never happen")); } else if (isPrimaryKeyError(serverMessage, config.getThirdPartyUsersTable()) || isPrimaryKeyError(serverMessage, config.getUsersTable()) || isPrimaryKeyError(serverMessage, config.getThirdPartyUserToTenantTable()) @@ -1161,11 +1190,19 @@ public void signUpMultipleViaBulkImport_Transaction(TransactionConnection connec errorByPosition.put(users.get(position).userId, new io.supertokens.pluginInterface.thirdparty.exception.DuplicateUserIdException()); } else if (isForeignKeyConstraintError(serverMessage, config.getAppIdToUserIdTable(), "app_id")) { - errorByPosition.put(users.get(position).userId, new TenantOrAppNotFoundException(users.get(position).tenantIdentifier.toAppIdentifier())); + errorByPosition.put(users.get(position).userId, new TenantOrAppNotFoundException(users.get(position).appIdentifier)); } else if (isForeignKeyConstraintError(serverMessage, config.getUsersTable(), "tenant_id")) { - errorByPosition.put(users.get(position).userId,new TenantOrAppNotFoundException(users.get(position).tenantIdentifier)); + EmailPasswordImportUser user = null; + for (var u : users) { + if (position < u.recipeUserTenantIds.size()) { + user = u; + break; + } + position -= u.recipeUserTenantIds.size(); + } + assert user != null; + errorByPosition.put(user.userId, new TenantOrAppNotFoundException(new TenantIdentifier(user.appIdentifier.getConnectionUriDomain(), user.appIdentifier.getAppId(), user.recipeUserTenantIds.get(position)))); } - } nextException = nextException.getNextException(); } @@ -1279,9 +1316,12 @@ public void updateUsersPassword_Transaction(AppIdentifier appIdentifier, Transac @Override public void updateUsersEmail_Transaction(AppIdentifier appIdentifier, TransactionConnection conn, String userId, String email) - throws StorageQueryException, DuplicateEmailException { + throws StorageQueryException, DuplicateEmailException, EmailChangeNotAllowedException, + UnknownUserIdException { Connection sqlCon = (Connection) conn.getConnection(); try { + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, appIdentifier, userId, + ACCOUNT_INFO_TYPE.EMAIL, email); EmailPasswordQueries.updateUsersEmail_Transaction(this, sqlCon, appIdentifier, userId, email); } catch (SQLException e) { if (e instanceof PSQLException && isUniqueConstraintError(((PSQLException) e).getServerErrorMessage(), @@ -1290,6 +1330,8 @@ public void updateUsersEmail_Transaction(AppIdentifier appIdentifier, Transactio } throw new StorageQueryException(e); + } catch (DuplicatePhoneNumberException | DuplicateThirdPartyUserException | PhoneNumberChangeNotAllowedException e) { + throw new IllegalStateException("should never happen"); } } @@ -1535,13 +1577,19 @@ public void deleteExpiredPasswordResetTokens() throws StorageQueryException { } @Override - public void updateUserEmail_Transaction(AppIdentifier appIdentifier, TransactionConnection con, + public void updateUserEmail_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String userId, String thirdPartyId, String thirdPartyUserId, - String newEmail) throws StorageQueryException { + String newEmail) + throws StorageQueryException, EmailChangeNotAllowedException, DuplicateEmailException, + UnknownUserIdException { Connection sqlCon = (Connection) con.getConnection(); try { + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, appIdentifier, userId, + ACCOUNT_INFO_TYPE.EMAIL, newEmail); ThirdPartyQueries.updateUserEmail_Transaction(this, sqlCon, appIdentifier, thirdPartyId, thirdPartyUserId, newEmail); + } catch (PhoneNumberChangeNotAllowedException | DuplicatePhoneNumberException | DuplicateThirdPartyUserException e) { + throw new IllegalStateException("should never happen"); } catch (SQLException e) { throw new StorageQueryException(e); } @@ -1565,6 +1613,9 @@ public AuthRecipeUserInfo signUp( "third_party_user_id")) { throw new DuplicateThirdPartyUserException(); + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserTenantsTable())) { + throw new DuplicateThirdPartyUserException(); + } else if (isPrimaryKeyError(serverMessage, config.getThirdPartyUsersTable()) || isPrimaryKeyError(serverMessage, config.getUsersTable()) || isPrimaryKeyError(serverMessage, config.getThirdPartyUserToTenantTable()) @@ -1604,11 +1655,11 @@ public void deleteThirdPartyUser_Transaction(TransactionConnection con, AppIdent @Override public void importThirdPartyUsers_Transaction(TransactionConnection con, - List usersToImport) + List users) throws StorageQueryException, StorageTransactionLogicException, TenantOrAppNotFoundException { try { Connection sqlCon = (Connection) con.getConnection(); - ThirdPartyQueries.importUser_Transaction(this, sqlCon, usersToImport); + ThirdPartyQueries.importUser_Transaction(this, sqlCon, users); } catch (SQLException e) { if (e instanceof BatchUpdateException batchUpdateException) { Map errorByPosition = new HashMap<>(); @@ -1620,23 +1671,38 @@ public void importThirdPartyUsers_Transaction(TransactionConnection con, ServerErrorMessage serverMessage = ((PSQLException) nextException).getServerErrorMessage(); int position = getErroneousEntryPosition(batchUpdateException); - if (isUniqueConstraintError(serverMessage, config.getEmailPasswordUserToTenantTable(), + if (isUniqueConstraintError(serverMessage, config.getThirdPartyUserToTenantTable(), "third_party_user_id")) { - errorByPosition.put(usersToImport.get(position).userId, new DuplicateThirdPartyUserException()); + errorByPosition.put(users.get(position).userId, new DuplicateThirdPartyUserException()); + + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserTenantsTable())) { + errorByPosition.put(users.get(position / 2).userId, new DuplicateThirdPartyUserException()); + + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserAccountInfosTable())) { + errorByPosition.put(users.get(position).userId, + new IllegalStateException("should never happen")); } else if (isPrimaryKeyError(serverMessage, config.getThirdPartyUsersTable()) || isPrimaryKeyError(serverMessage, config.getUsersTable()) || isPrimaryKeyError(serverMessage, config.getThirdPartyUserToTenantTable()) || isPrimaryKeyError(serverMessage, config.getAppIdToUserIdTable())) { - errorByPosition.put(usersToImport.get(position).userId, + errorByPosition.put(users.get(position).userId, new io.supertokens.pluginInterface.thirdparty.exception.DuplicateUserIdException()); - } - else if (isForeignKeyConstraintError(serverMessage, config.getAppIdToUserIdTable(), "app_id")) { - throw new TenantOrAppNotFoundException(usersToImport.get(position).tenantIdentifier.toAppIdentifier()); + } else if (isForeignKeyConstraintError(serverMessage, config.getAppIdToUserIdTable(), "app_id")) { + errorByPosition.put(users.get(position).userId, new TenantOrAppNotFoundException(users.get(position).appIdentifier)); } else if (isForeignKeyConstraintError(serverMessage, config.getUsersTable(), "tenant_id")) { - throw new TenantOrAppNotFoundException(usersToImport.get(position).tenantIdentifier); + ThirdPartyImportUser user = null; + for (var u : users) { + if (position < u.recipeUserTenantIds.size() * 2) { // multiplying by 2 since we add 2 account infos - one for email and one for thirdparty info + user = u; + break; + } + position -= u.recipeUserTenantIds.size(); + } + assert user != null; + errorByPosition.put(user.userId, new TenantOrAppNotFoundException(new TenantIdentifier(user.appIdentifier.getConnectionUriDomain(), user.appIdentifier.getAppId(), user.recipeUserTenantIds.get(position % user.recipeUserTenantIds.size())))); } } nextException = nextException.getNextException(); @@ -2017,59 +2083,6 @@ public void deleteCode_Transaction(TenantIdentifier tenantIdentifier, Transactio } } - @Override - public void updateUserEmail_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String userId, - String email) - throws StorageQueryException, UnknownUserIdException, DuplicateEmailException { - Connection sqlCon = (Connection) con.getConnection(); - try { - int updated_rows = PasswordlessQueries.updateUserEmail_Transaction(this, sqlCon, appIdentifier, userId, - email); - if (updated_rows != 1) { - throw new UnknownUserIdException(); - } - } catch (SQLException e) { - - if (e instanceof PSQLException) { - if (isUniqueConstraintError(((PSQLException) e).getServerErrorMessage(), - Config.getConfig(this).getPasswordlessUserToTenantTable(), "email")) { - throw new DuplicateEmailException(); - - } - } - throw new StorageQueryException(e); - - } - } - - @Override - public void updateUserPhoneNumber_Transaction(AppIdentifier appIdentifier, TransactionConnection - con, String userId, String phoneNumber) - throws StorageQueryException, UnknownUserIdException, DuplicatePhoneNumberException { - Connection sqlCon = (Connection) con.getConnection(); - try { - int updated_rows = PasswordlessQueries.updateUserPhoneNumber_Transaction(this, sqlCon, appIdentifier, - userId, - phoneNumber); - - if (updated_rows != 1) { - throw new UnknownUserIdException(); - } - - } catch (SQLException e) { - - if (e instanceof PSQLException) { - if (isUniqueConstraintError(((PSQLException) e).getServerErrorMessage(), - Config.getConfig(this).getPasswordlessUserToTenantTable(), "phone_number")) { - throw new DuplicatePhoneNumberException(); - - } - } - - throw new StorageQueryException(e); - } - } - @Override public void createDeviceWithCode(TenantIdentifier tenantIdentifier, @Nullable String email, @Nullable String phoneNumber, @NotNull String linkCodeSalt, @@ -2159,6 +2172,16 @@ public AuthRecipeUserInfo createUser(TenantIdentifier tenantIdentifier, PostgreSQLConfig config = Config.getConfig(this); ServerErrorMessage serverMessage = ((PSQLException) actualException).getServerErrorMessage(); + if (isPrimaryKeyError(serverMessage, config.getRecipeUserTenantsTable())) { + // For passwordless, recipe_user_tenants primary key error means duplicate email or phone number + // Determine which one based on what was provided + if (email != null) { + throw new DuplicateEmailException(); + } else { + throw new DuplicatePhoneNumberException(); + } + } + if (isPrimaryKeyError(serverMessage, config.getPasswordlessUsersTable()) || isPrimaryKeyError(serverMessage, config.getUsersTable()) || isPrimaryKeyError(serverMessage, config.getPasswordlessUserToTenantTable()) @@ -2230,8 +2253,7 @@ public void importPasswordlessUsers_Transaction(TransactionConnection con, || isPrimaryKeyError(serverMessage, config.getPasswordlessUserToTenantTable()) || isPrimaryKeyError(serverMessage, config.getAppIdToUserIdTable())) { errorByPosition.put(users.get(position).userId, new DuplicateUserIdException()); - } - if (isUniqueConstraintError(serverMessage, config.getPasswordlessUserToTenantTable(), + } else if (isUniqueConstraintError(serverMessage, config.getPasswordlessUserToTenantTable(), "email")) { errorByPosition.put(users.get(position).userId, new DuplicateEmailException()); @@ -2239,13 +2261,51 @@ public void importPasswordlessUsers_Transaction(TransactionConnection con, "phone_number")) { errorByPosition.put(users.get(position).userId, new DuplicatePhoneNumberException()); + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserAccountInfosTable())) { + errorByPosition.put(users.get(position).userId, + new IllegalStateException("should never happen")); + + } else if (isPrimaryKeyError(serverMessage, config.getRecipeUserTenantsTable())) { + PasswordlessImportUser user = null; + for (var u : users) { + int acCount = (u.email == null ? 0 : 1) + (u.phoneNumber == null ? 0 : 1); + int tCount = u.recipeUserTenantIds.size(); + if (position < acCount * tCount) { + user = u; + break; + } + position -= u.recipeUserTenantIds.size(); + } + assert user != null; + if (user.email != null) { + errorByPosition.put(user.userId, new DuplicateEmailException()); + } else { + errorByPosition.put(user.userId, new DuplicatePhoneNumberException()); + } + } else if (isForeignKeyConstraintError(serverMessage, config.getAppIdToUserIdTable(), "app_id")) { - throw new TenantOrAppNotFoundException(users.get(position).tenantIdentifier.toAppIdentifier()); + errorByPosition.put(users.get(position).userId, new TenantOrAppNotFoundException(users.get(position).appIdentifier)); } else if (isForeignKeyConstraintError(serverMessage, config.getUsersTable(), "tenant_id")) { - throw new TenantOrAppNotFoundException(users.get(position).tenantIdentifier.toAppIdentifier()); + PasswordlessImportUser user = null; + for (var u : users) { + int acCount = (u.email == null ? 0 : 1) + (u.phoneNumber == null ? 0 : 1); + int tCount = u.recipeUserTenantIds.size(); + if (position < acCount * tCount) { + user = u; + break; + } + position -= u.recipeUserTenantIds.size(); + } + assert user != null; + if (user.email != null) { + errorByPosition.put(user.userId, new DuplicateEmailException()); + } else { + errorByPosition.put(user.userId, new DuplicatePhoneNumberException()); + } + errorByPosition.put(user.userId, new TenantOrAppNotFoundException(new TenantIdentifier(user.appIdentifier.getConnectionUriDomain(), user.appIdentifier.getAppId(), user.recipeUserTenantIds.get(position)))); } } nextException = nextException.getNextException(); @@ -2257,6 +2317,71 @@ public void importPasswordlessUsers_Transaction(TransactionConnection con, } } + @Override + public void updateUserEmailAndPhone_Transaction(AppIdentifier appIdentifier, TransactionConnection con, + String userId, String email, boolean shouldUpdateEmail, + String phoneNumber, boolean shouldUpdatePhoneNumber) + throws StorageQueryException, UnknownUserIdException, DuplicateEmailException, + EmailChangeNotAllowedException, DuplicatePhoneNumberException, PhoneNumberChangeNotAllowedException { + + try { + Connection sqlCon = (Connection) con.getConnection(); + + // Update non-nulls first + if (email != null) { + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, appIdentifier, userId, ACCOUNT_INFO_TYPE.EMAIL, email); + int updated_rows = PasswordlessQueries.updateUserEmail_Transaction(this, sqlCon, appIdentifier, userId, + email); + if (updated_rows != 1) { + throw new UnknownUserIdException(); + } + } + if (phoneNumber != null) { + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, appIdentifier, userId, ACCOUNT_INFO_TYPE.PHONE_NUMBER, phoneNumber); + int updated_rows = PasswordlessQueries.updateUserPhoneNumber_Transaction(this, sqlCon, appIdentifier, userId, + phoneNumber); + if (updated_rows != 1) { + throw new UnknownUserIdException(); + } + } + + // now update the nulls + if (email == null && shouldUpdateEmail) { + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, appIdentifier, userId, ACCOUNT_INFO_TYPE.EMAIL, email); + int updated_rows = PasswordlessQueries.updateUserEmail_Transaction(this, sqlCon, appIdentifier, userId, + email); + if (updated_rows != 1) { + throw new UnknownUserIdException(); + } + } + if (phoneNumber == null && shouldUpdatePhoneNumber) { + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, appIdentifier, userId, ACCOUNT_INFO_TYPE.PHONE_NUMBER, phoneNumber); + int updated_rows = PasswordlessQueries.updateUserPhoneNumber_Transaction(this, sqlCon, appIdentifier, userId, + phoneNumber); + if (updated_rows != 1) { + throw new UnknownUserIdException(); + } + } + + } catch (SQLException e) { + if (e instanceof PSQLException) { + if (isUniqueConstraintError(((PSQLException) e).getServerErrorMessage(), + Config.getConfig(this).getPasswordlessUserToTenantTable(), "email")) { + throw new DuplicateEmailException(); + + } + if (isUniqueConstraintError(((PSQLException) e).getServerErrorMessage(), + Config.getConfig(this).getPasswordlessUserToTenantTable(), "phone_number")) { + throw new DuplicatePhoneNumberException(); + } + } + throw new StorageQueryException(e); + + } catch (DuplicateThirdPartyUserException e) { + throw new IllegalStateException("should never happen", e); + } + } + @Override public PasswordlessDevice getDevice(TenantIdentifier tenantIdentifier, String deviceIdHash) throws StorageQueryException { @@ -2913,6 +3038,8 @@ public boolean addUserIdToTenant_Transaction(TenantIdentifier tenantIdentifier, throw new UnknownUserIdException(); } + AccountInfoQueries.addTenantIdToRecipeUser_Transaction(this, sqlCon, tenantIdentifier, userId); + boolean added; if (recipeId.equals("emailpassword")) { added = EmailPasswordQueries.addUserIdToTenant_Transaction(this, sqlCon, tenantIdentifier, @@ -2983,6 +3110,8 @@ public boolean removeUserIdFromTenant(TenantIdentifier tenantIdentifier, String } else { throw new IllegalStateException("Should never come here!"); } + AccountInfoQueries.removeAccountInfoReservationForPrimaryUserWhileRemovingTenant_Transaction(this, sqlCon, tenantIdentifier, userId); + AccountInfoQueries.removeAccountInfoForRecipeUserWhileRemovingTenant_Transaction(this, sqlCon, tenantIdentifier, userId); sqlCon.commit(); return removed; @@ -3461,57 +3590,6 @@ public AuthRecipeUserInfo getPrimaryUserById_Transaction(AppIdentifier appIdenti } } - @Override - public List getPrimaryUsersByIds_Transaction(AppIdentifier appIdentifier, TransactionConnection con, - List userIds) - throws StorageQueryException { - try { - Connection sqlCon = (Connection) con.getConnection(); - return GeneralQueries.getPrimaryUserInfosForUserIds_Transaction(this, sqlCon, appIdentifier, userIds); - } catch (SQLException e) { - throw new StorageQueryException(e); - } - } - - @Override - public AuthRecipeUserInfo[] listPrimaryUsersByEmail_Transaction(AppIdentifier appIdentifier, - TransactionConnection con, String email) - throws StorageQueryException { - try { - Connection sqlCon = (Connection) con.getConnection(); - return GeneralQueries.listPrimaryUsersByEmail_Transaction(this, sqlCon, appIdentifier, email); - } catch (SQLException e) { - throw new StorageQueryException(e); - } - } - - @Override - public AuthRecipeUserInfo[] listPrimaryUsersByMultipleEmailsOrPhoneNumbersOrThirdparty_Transaction( - AppIdentifier appIdentifier, TransactionConnection con, List emails, List phones, - Map thirdpartyIdToThirdpartyUserId) throws StorageQueryException { - try { - Connection sqlCon = (Connection) con.getConnection(); - return GeneralQueries.listPrimaryUsersByMultipleEmailsOrPhonesOrThirdParty_Transaction(this, sqlCon, - appIdentifier, emails, phones, thirdpartyIdToThirdpartyUserId); - } catch (SQLException e) { - throw new StorageQueryException(e); - } - } - - @Override - public AuthRecipeUserInfo[] listPrimaryUsersByPhoneNumber_Transaction(AppIdentifier appIdentifier, - TransactionConnection con, - String phoneNumber) - throws StorageQueryException { - try { - Connection sqlCon = (Connection) con.getConnection(); - return GeneralQueries.listPrimaryUsersByPhoneNumber_Transaction(this, sqlCon, appIdentifier, - phoneNumber); - } catch (SQLException e) { - throw new StorageQueryException(e); - } - } - @Override public AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo(AppIdentifier appIdentifier, String thirdPartyId, @@ -3541,76 +3619,137 @@ public AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo_Transaction(AppIden } @Override - public void makePrimaryUser_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String userId) - throws StorageQueryException { + public boolean makePrimaryUser_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String userId) + throws StorageQueryException, AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, + CannotBecomePrimarySinceRecipeUserIdAlreadyLinkedWithPrimaryUserIdException, UnknownUserIdException { try { Connection sqlCon = (Connection) con.getConnection(); // we do not bother returning if a row was updated here or not, cause it's happening // in a transaction anyway. - GeneralQueries.makePrimaryUser_Transaction(this, sqlCon, appIdentifier, userId); + + boolean didBecomePrimary = AccountInfoQueries.addPrimaryUserAccountInfo_Transaction(this, sqlCon, appIdentifier, userId); + if (didBecomePrimary) { + GeneralQueries.makePrimaryUser_Transaction(this, sqlCon, appIdentifier, userId); + } + return didBecomePrimary; } catch (SQLException e) { throw new StorageQueryException(e); } } @Override - public void makePrimaryUsers_Transaction(AppIdentifier appIdentifier, TransactionConnection con, - List userIds) throws StorageQueryException { + public boolean linkAccounts_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String recipeUserId, + String primaryUserId) + throws StorageQueryException, CannotLinkSinceRecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException, + AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, InputUserIdIsNotAPrimaryUserException, + UnknownUserIdException { try { Connection sqlCon = (Connection) con.getConnection(); - GeneralQueries.makePrimaryUsers_Transaction(this, sqlCon, appIdentifier, userIds); + boolean didLinkAccounts = AccountInfoQueries.reserveAccountInfoForLinking_Transaction(this, sqlCon, appIdentifier, recipeUserId, primaryUserId); + if (didLinkAccounts) { + GeneralQueries.linkAccounts_Transaction(this, sqlCon, appIdentifier, recipeUserId, primaryUserId); + } + return didLinkAccounts; } catch (SQLException e) { throw new StorageQueryException(e); } } @Override - public void linkAccounts_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String recipeUserId, - String primaryUserId) throws StorageQueryException { + public void unlinkAccounts_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String primaryUserId, + String recipeUserId) + throws StorageQueryException { try { Connection sqlCon = (Connection) con.getConnection(); // we do not bother returning if a row was updated here or not, cause it's happening // in a transaction anyway. - GeneralQueries.linkAccounts_Transaction(this, sqlCon, appIdentifier, recipeUserId, primaryUserId); + GeneralQueries.unlinkAccounts_Transaction(this, sqlCon, appIdentifier, primaryUserId, recipeUserId); + AccountInfoQueries.removeAccountInfoReservationForPrimaryUserForUnlinking_Transaction(this, sqlCon, appIdentifier, recipeUserId); } catch (SQLException e) { throw new StorageQueryException(e); } } @Override - public void linkMultipleAccounts_Transaction(AppIdentifier appIdentifier, TransactionConnection con, - Map recipeUserIdByPrimaryUserId) - throws StorageQueryException { + public boolean doesUserIdExist_Transaction(TransactionConnection con, AppIdentifier appIdentifier, + String externalUserId) throws StorageQueryException { try { Connection sqlCon = (Connection) con.getConnection(); - GeneralQueries.linkMultipleAccounts_Transaction(this, sqlCon, appIdentifier, recipeUserIdByPrimaryUserId); + return GeneralQueries.doesUserIdExist_Transaction(this, sqlCon, appIdentifier, externalUserId); } catch (SQLException e) { throw new StorageQueryException(e); } + } + @Override + public CanBecomePrimaryResult checkIfLoginMethodCanBecomePrimary(AppIdentifier appIdentifier, String recipeUserId) + throws StorageQueryException, UnknownUserIdException { + return AccountInfoQueries.checkIfLoginMethodCanBecomePrimary(this, appIdentifier, recipeUserId); } @Override - public void unlinkAccounts_Transaction(AppIdentifier appIdentifier, TransactionConnection con, String primaryUserId, - String recipeUserId) - throws StorageQueryException { - try { - Connection sqlCon = (Connection) con.getConnection(); - // we do not bother returning if a row was updated here or not, cause it's happening - // in a transaction anyway. - GeneralQueries.unlinkAccounts_Transaction(this, sqlCon, appIdentifier, primaryUserId, recipeUserId); - } catch (SQLException e) { - throw new StorageQueryException(e); - } + public io.supertokens.pluginInterface.authRecipe.CanLinkAccountsResult checkIfLoginMethodsCanBeLinked( + AppIdentifier appIdentifier, + String primaryUserId, String recipeUserId) throws StorageQueryException, UnknownUserIdException { + return AccountInfoQueries.checkIfLoginMethodsCanBeLinked(this, appIdentifier, + primaryUserId, recipeUserId); } @Override - public boolean doesUserIdExist_Transaction(TransactionConnection con, AppIdentifier appIdentifier, - String externalUserId) throws StorageQueryException { + public void addTenantIdToPrimaryUser_Transaction(TenantIdentifier tenantIdentifier, TransactionConnection con, + String supertokensUserId) + throws AnotherPrimaryUserWithPhoneNumberAlreadyExistsException, + AnotherPrimaryUserWithEmailAlreadyExistsException, + AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException, + StorageQueryException { + AccountInfoQueries.addTenantIdToPrimaryUser_Transaction(this, con, tenantIdentifier, supertokensUserId); + } + + @Override + public void deleteAccountInfoReservations_Transaction(TransactionConnection con, AppIdentifier appIdentifier, + String userId) throws StorageQueryException { + AccountInfoQueries.removeAccountInfoReservationsForDeletingUser_Transaction(this, con, appIdentifier, userId); + } + + @Override + public void reservePrimaryUserAccountInfos_Transaction(TransactionConnection con, List primaryUsers) + throws StorageQueryException, StorageTransactionLogicException { try { - Connection sqlCon = (Connection) con.getConnection(); - return GeneralQueries.doesUserIdExist_Transaction(this, sqlCon, appIdentifier, externalUserId); + AccountInfoQueries.reservePrimaryUserAccountInfos_Transaction(this, con, primaryUsers); } catch (SQLException e) { + if (e instanceof BatchUpdateException batchUpdateException) { + Map errorByPosition = new HashMap<>(); + SQLException nextException = batchUpdateException.getNextException(); + while (nextException != null) { + if (nextException instanceof PSQLException) { + PostgreSQLConfig config = Config.getConfig(this); + ServerErrorMessage serverMessage = ((PSQLException) nextException).getServerErrorMessage(); + + int position = getErroneousEntryPosition(batchUpdateException); + if (isPrimaryKeyError(serverMessage, config.getPrimaryUserTenantsTable())) { + // The batch operation flattens all primary users into a single batch where each + // PrimaryUser contributes (accountInfos.size() * tenantIds.size()) entries. + // When an error occurs, we need to map the flat batch position back to the specific + // PrimaryUser that caused the error. We do this by iterating through primaryUsers and + // subtracting each one's entry count from the position until we find the one where + // the position falls within its range (position < entries for that PrimaryUser). + PrimaryUser primaryUser = null; + for (var pu : primaryUsers) { + if (position < pu.accountInfos.size() * pu.tenantIds.size()) { + primaryUser = pu; + break; + } + + position -= pu.accountInfos.size() * pu.tenantIds.size(); + } + assert primaryUser != null; + errorByPosition.put(primaryUser.primaryUserId, new AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException(primaryUser.primaryUserId, "E027: there is a conflicting account info")); + } + } + nextException = nextException.getNextException(); + } + throw new StorageTransactionLogicException(new BulkImportBatchInsertException("account linking errors", errorByPosition)); + } throw new StorageQueryException(e); } } @@ -4117,7 +4256,7 @@ public boolean isOAuthTokenRevokedByJTI(AppIdentifier appIdentifier, String gid, public WebAuthNStoredCredential saveCredentials(TenantIdentifier tenantIdentifier, WebAuthNStoredCredential credential) throws StorageQueryException, io.supertokens.pluginInterface.webauthn.exceptions.DuplicateCredentialException, - TenantOrAppNotFoundException, io.supertokens.pluginInterface.webauthn.exceptions.UserIdNotFoundException { + TenantOrAppNotFoundException, UnknownUserIdException { try { return WebAuthNQueries.saveCredential(this, tenantIdentifier, credential); } catch (SQLException e) { @@ -4130,7 +4269,7 @@ public WebAuthNStoredCredential saveCredentials(TenantIdentifier tenantIdentifie errorMessage, config.getWebAuthNCredentialsTable(), "user_id")) { - throw new io.supertokens.pluginInterface.webauthn.exceptions.UserIdNotFoundException(); + throw new UnknownUserIdException(); } else if (isForeignKeyConstraintError( errorMessage, config.getAppsTable(), @@ -4220,7 +4359,7 @@ public WebAuthNStoredCredential loadCredentialById_Transaction(TenantIdentifier public AuthRecipeUserInfo signUpWithCredentialsRegister_Transaction(TenantIdentifier tenantIdentifier, TransactionConnection con, String userId, String email, String relyingPartyId, WebAuthNStoredCredential credential) throws StorageQueryException, io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserIdException, TenantOrAppNotFoundException, - DuplicateUserEmailException { + DuplicateEmailException { Connection sqlCon = (Connection) con.getConnection(); try { return WebAuthNQueries.signUpWithCredentialRegister_Transaction(this, sqlCon, tenantIdentifier, userId, email, relyingPartyId, credential); @@ -4232,7 +4371,9 @@ public AuthRecipeUserInfo signUpWithCredentialsRegister_Transaction(TenantIdenti if (isUniqueConstraintError(errorMessage, config.getWebAuthNUserToTenantTable(),"email")) { Logging.error(this, errorMessage.getMessage(), true); Logging.error(this, email, true); - throw new DuplicateUserEmailException(); + throw new DuplicateEmailException(); + } else if (isPrimaryKeyError(errorMessage, config.getRecipeUserTenantsTable())) { + throw new DuplicateEmailException(); } else if (isPrimaryKeyError(errorMessage, config.getWebAuthNUsersTable()) || isPrimaryKeyError(errorMessage, config.getUsersTable()) || isPrimaryKeyError(errorMessage, config.getWebAuthNUserToTenantTable()) @@ -4260,7 +4401,7 @@ public AuthRecipeUserInfo signUpWithCredentialsRegister_Transaction(TenantIdenti @Override public AuthRecipeUserInfo signUp_Transaction(TenantIdentifier tenantIdentifier, TransactionConnection con, String userId, String email, String relyingPartyId) - throws StorageQueryException, TenantOrAppNotFoundException, DuplicateUserEmailException, + throws StorageQueryException, TenantOrAppNotFoundException, DuplicateEmailException, io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserIdException { Connection sqlCon = (Connection) con.getConnection(); try { @@ -4271,7 +4412,9 @@ public AuthRecipeUserInfo signUp_Transaction(TenantIdentifier tenantIdentifier, PostgreSQLConfig config = Config.getConfig(this); if (isUniqueConstraintError(errorMessage, config.getWebAuthNUserToTenantTable(),"email")) { - throw new DuplicateUserEmailException(); + throw new DuplicateEmailException(); + } else if (isPrimaryKeyError(errorMessage, config.getRecipeUserTenantsTable())) { + throw new DuplicateEmailException(); } else if (isPrimaryKeyError(errorMessage, config.getWebAuthNUsersTable()) || isPrimaryKeyError(errorMessage, config.getUsersTable()) || isPrimaryKeyError(errorMessage, config.getWebAuthNUserToTenantTable()) @@ -4368,8 +4511,8 @@ public List listCredentialsForUser(TenantIdentifier te @Override public void updateUserEmail(TenantIdentifier tenantIdentifier, String userId, String newEmail) - throws StorageQueryException, io.supertokens.pluginInterface.webauthn.exceptions.UserIdNotFoundException, - DuplicateUserEmailException { + throws StorageQueryException, UnknownUserIdException, + DuplicateEmailException { try { WebAuthNQueries.updateUserEmail(this, tenantIdentifier, userId, newEmail); } catch (StorageQueryException e) { @@ -4379,9 +4522,9 @@ public void updateUserEmail(TenantIdentifier tenantIdentifier, String userId, St if (isUniqueConstraintError(errorMessage, config.getWebAuthNUserToTenantTable(), "email")) { - throw new DuplicateUserEmailException(); + throw new DuplicateEmailException(); } else if (isForeignKeyConstraintError(errorMessage,config.getWebAuthNUserToTenantTable(),"user_id")) { - throw new io.supertokens.pluginInterface.webauthn.exceptions.UserIdNotFoundException(); + throw new UnknownUserIdException(); } } throw new StorageQueryException(e); @@ -4391,10 +4534,11 @@ public void updateUserEmail(TenantIdentifier tenantIdentifier, String userId, St @Override public void updateUserEmail_Transaction(TenantIdentifier tenantIdentifier, TransactionConnection con, String userId, String newEmail) - throws StorageQueryException, io.supertokens.pluginInterface.webauthn.exceptions.UserIdNotFoundException, - DuplicateUserEmailException { + throws StorageQueryException, UnknownUserIdException, + DuplicateEmailException, EmailChangeNotAllowedException, UnknownUserIdException { try { Connection sqlCon = (Connection) con.getConnection(); + AccountInfoQueries.updateAccountInfo_Transaction(this, sqlCon, tenantIdentifier.toAppIdentifier(), userId, ACCOUNT_INFO_TYPE.EMAIL, newEmail); WebAuthNQueries.updateUserEmail_Transaction(this, sqlCon, tenantIdentifier, userId, newEmail); } catch (StorageQueryException e) { if (e.getCause() instanceof SQLException){ @@ -4403,12 +4547,14 @@ public void updateUserEmail_Transaction(TenantIdentifier tenantIdentifier, Trans if (isUniqueConstraintError(errorMessage, config.getWebAuthNUserToTenantTable(), "email")) { - throw new DuplicateUserEmailException(); + throw new DuplicateEmailException(); } else if (isForeignKeyConstraintError(errorMessage,config.getWebAuthNUserToTenantTable(),"user_id")) { - throw new io.supertokens.pluginInterface.webauthn.exceptions.UserIdNotFoundException(); + throw new UnknownUserIdException(); } } throw new StorageQueryException(e); + } catch (PhoneNumberChangeNotAllowedException | DuplicatePhoneNumberException | DuplicateThirdPartyUserException e) { + throw new IllegalStateException("should never happen"); } } diff --git a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java index e289f766..eed6a799 100644 --- a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java +++ b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java @@ -506,6 +506,17 @@ public String getBulkImportUsersTable() { return addSchemaAndPrefixToTableName("bulk_import_users"); } + public String getRecipeUserAccountInfosTable() { + return addSchemaAndPrefixToTableName("recipe_user_account_infos"); + } + + public String getRecipeUserTenantsTable() { + return addSchemaAndPrefixToTableName("recipe_user_tenants"); + } + + public String getPrimaryUserTenantsTable() { + return addSchemaAndPrefixToTableName("primary_user_tenants"); + } private String addSchemaAndPrefixToTableName(String tableName) { return addSchemaToTableName(postgresql_table_names_prefix + tableName); diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java new file mode 100644 index 00000000..6e80fb33 --- /dev/null +++ b/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java @@ -0,0 +1,1299 @@ +/* + * Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.postgresql.queries; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.postgresql.util.PSQLException; +import org.postgresql.util.ServerErrorMessage; + +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.authRecipe.CanBecomePrimaryResult; +import io.supertokens.pluginInterface.authRecipe.CanLinkAccountsResult; +import io.supertokens.pluginInterface.authRecipe.exceptions.AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.AnotherPrimaryUserWithEmailAlreadyExistsException; +import io.supertokens.pluginInterface.authRecipe.exceptions.AnotherPrimaryUserWithPhoneNumberAlreadyExistsException; +import io.supertokens.pluginInterface.authRecipe.exceptions.AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException; +import io.supertokens.pluginInterface.authRecipe.exceptions.CannotBecomePrimarySinceRecipeUserIdAlreadyLinkedWithPrimaryUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.CannotLinkSinceRecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.EmailChangeNotAllowedException; +import io.supertokens.pluginInterface.authRecipe.exceptions.InputUserIdIsNotAPrimaryUserException; +import io.supertokens.pluginInterface.authRecipe.exceptions.PhoneNumberChangeNotAllowedException; +import io.supertokens.pluginInterface.authRecipe.exceptions.UnknownUserIdException; +import io.supertokens.pluginInterface.bulkimport.PrimaryUser; +import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateEmailException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.passwordless.exception.DuplicatePhoneNumberException; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; +import io.supertokens.pluginInterface.thirdparty.exception.DuplicateThirdPartyUserException; +import io.supertokens.storage.postgresql.PreparedStatementValueSetter; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.executeBatch; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; +import io.supertokens.storage.postgresql.Start; +import io.supertokens.storage.postgresql.config.Config; +import static io.supertokens.storage.postgresql.config.Config.getConfig; +import io.supertokens.storage.postgresql.utils.Utils; + +public class AccountInfoQueries { + static String getQueryToCreateRecipeUserAccountInfosTable(Start start) { + String schema = Config.getConfig(start).getTableSchema(); + String tableName = Config.getConfig(start).getRecipeUserAccountInfosTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + tableName + " (" + + "app_id VARCHAR(64) NOT NULL," + + "recipe_user_id CHAR(36) NOT NULL," + + "recipe_id VARCHAR(128) NOT NULL," + + "account_info_type VARCHAR(8) NOT NULL," + + "account_info_value TEXT NOT NULL," + + "third_party_id VARCHAR(28)," + + "third_party_user_id VARCHAR(256)," + + "primary_user_id CHAR(36) NULL," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, null, "pkey") + + " PRIMARY KEY (app_id, recipe_id, recipe_user_id, account_info_type, third_party_id, third_party_user_id)," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, "tenant_id", "fkey") + + " FOREIGN KEY(app_id)" + + " REFERENCES " + Config.getConfig(start).getAppsTable() + " (app_id) ON DELETE CASCADE" + + ");"; + // @formatter:on + } + + static String getQueryToCreateRecipeUserTenantsTable(Start start) { + String schema = Config.getConfig(start).getTableSchema(); + String tableName = Config.getConfig(start).getRecipeUserTenantsTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + tableName + " (" + + "app_id VARCHAR(64) NOT NULL," + + "recipe_user_id CHAR(36) NOT NULL," + + "tenant_id VARCHAR(64) NOT NULL," + + "recipe_id VARCHAR(128) NOT NULL," + + "account_info_type VARCHAR(8) NOT NULL," + + "account_info_value TEXT NOT NULL," + + "third_party_id VARCHAR(28)," + + "third_party_user_id VARCHAR(256)," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, null, "pkey") + + " PRIMARY KEY (app_id, tenant_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value)," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, "tenant_id", "fkey") + + " FOREIGN KEY(app_id, tenant_id)" + + " REFERENCES " + Config.getConfig(start).getTenantsTable() + " (app_id, tenant_id) ON DELETE CASCADE" + + ");"; + // @formatter:on + } + + static String getQueryToCreatePrimaryUserTenantsTable(Start start) { + String schema = Config.getConfig(start).getTableSchema(); + String tableName = Config.getConfig(start).getPrimaryUserTenantsTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + tableName + " (" + + "app_id VARCHAR(64) NOT NULL," + + "tenant_id VARCHAR(64) NOT NULL," + + "account_info_type VARCHAR(8) NOT NULL," + + "account_info_value TEXT NOT NULL," + + "primary_user_id CHAR(36) NOT NULL," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, null, "pkey") + + " PRIMARY KEY (app_id, tenant_id, account_info_type, account_info_value)," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, "app_id", "fkey") + + " FOREIGN KEY(app_id, tenant_id)" + + " REFERENCES " + Config.getConfig(start).getTenantsTable() + " (app_id, tenant_id) ON DELETE CASCADE" + + ");"; + // @formatter:on + } + + static String getQueryToCreateTenantIndexForRecipeUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_recipe_user_tenants_tenant ON " + + Config.getConfig(start).getRecipeUserTenantsTable() + "(app_id, tenant_id);"; + } + + static String getQueryToCreateRecipeUserIdIndexForRecipeUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_recipe_user_tenants_recipe_user_id ON " + + Config.getConfig(start).getRecipeUserTenantsTable() + "(recipe_user_id);"; + } + + static String getQueryToCreateAccountInfoIndexForRecipeUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_recipe_user_tenants_account_info ON " + + Config.getConfig(start).getRecipeUserTenantsTable() + + "(app_id, tenant_id, account_info_type, third_party_id, account_info_value);"; + } + + static String getQueryToCreatePrimaryUserIndexForPrimaryUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_primary_user_tenants_primary ON " + + Config.getConfig(start).getPrimaryUserTenantsTable() + "(primary_user_id);"; + } + + private static boolean isPrimaryKeyError(ServerErrorMessage serverMessage, String tableName) { + if (serverMessage == null || tableName == null) { + return false; + } + String[] tableNameParts = tableName.split("\\."); + tableName = tableNameParts[tableNameParts.length - 1]; + return "23505".equals(serverMessage.getSQLState()) && serverMessage.getConstraint() != null + && serverMessage.getConstraint().equals(tableName + "_pkey"); + } + + private static void throwAccountInfoChangeNotAllowed(ACCOUNT_INFO_TYPE accountInfoType) + throws EmailChangeNotAllowedException, PhoneNumberChangeNotAllowedException { + if (ACCOUNT_INFO_TYPE.EMAIL.equals(accountInfoType)) { + throw new EmailChangeNotAllowedException(); + } + if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.equals(accountInfoType)) { + throw new PhoneNumberChangeNotAllowedException(); + } + throw new IllegalArgumentException( + "updateAccountInfo_Transaction should only be called with accountInfoType EMAIL or PHONE_NUMBER"); + } + + private static void throwPrimaryUserTenantsConflict(String[] conflict) + throws AnotherPrimaryUserWithPhoneNumberAlreadyExistsException, + AnotherPrimaryUserWithEmailAlreadyExistsException, + AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException { + if (conflict == null) { + return; + } + String conflictingPrimaryUserId = conflict[0]; + String accountInfoType = conflict[1]; + + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + throw new AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException(conflictingPrimaryUserId); + } + + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + throw new AnotherPrimaryUserWithEmailAlreadyExistsException(conflictingPrimaryUserId); + } + + if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + throw new AnotherPrimaryUserWithPhoneNumberAlreadyExistsException(conflictingPrimaryUserId); + } + } + + private static void throwRecipeUserTenantsConflict(String accountInfoType, boolean shouldThrowChangeNotAllowedExceptions) + throws DuplicateEmailException, DuplicatePhoneNumberException, DuplicateThirdPartyUserException, + EmailChangeNotAllowedException, PhoneNumberChangeNotAllowedException { + if (accountInfoType == null) { + return; + } + + // this can never be updating + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + throw new DuplicateThirdPartyUserException(); + } + + if (shouldThrowChangeNotAllowedExceptions) { + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + throw new EmailChangeNotAllowedException(); + } + if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + throw new PhoneNumberChangeNotAllowedException(); + } + } else { + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + throw new DuplicateEmailException(); + } + if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + throw new DuplicatePhoneNumberException(); + } + } + } + + public static void addRecipeUserAccountInfo_Transaction(Start start, Connection sqlCon, + TenantIdentifier tenantIdentifier, String userId, + String recipeId, ACCOUNT_INFO_TYPE accountInfoType, + String thirdPartyId, String thirdPartyUserId, + String accountInfoValue) + throws SQLException { + { + String QUERY = "INSERT INTO " + getConfig(start).getRecipeUserAccountInfosTable() + + "(app_id, recipe_user_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value, primary_user_id)" + + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, recipeId); + pst.setString(4, accountInfoType.toString()); + pst.setString(5, thirdPartyId); + pst.setString(6, thirdPartyUserId); + pst.setString(7, accountInfoValue); + pst.setObject(8, null); // primary_user_id is NULL initially + }); + } + + { + String QUERY = "INSERT INTO " + getConfig(start).getRecipeUserTenantsTable() + + "(app_id, recipe_user_id, tenant_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value)" + + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, tenantIdentifier.getTenantId()); + pst.setString(4, recipeId); + pst.setString(5, accountInfoType.toString()); + pst.setString(6, thirdPartyId); + pst.setString(7, thirdPartyUserId); + pst.setString(8, accountInfoValue); + }); + } + } + + public static boolean addPrimaryUserAccountInfo_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, String userId) throws + StorageQueryException, AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, + CannotBecomePrimarySinceRecipeUserIdAlreadyLinkedWithPrimaryUserIdException, UnknownUserIdException { + try { + String schema = Config.getConfig(start).getTableSchema(); + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + + // Ensure same user doesn't become primary in parallel + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock(sqlCon, appIdentifier.getAppId() + "~" + userId); + + // Insert with ON CONFLICT to catch primary key violations + String QUERY = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT r.app_id, r.tenant_id, r.account_info_type, r.account_info_value, ?" + + " FROM " + recipeUserTenantsTable + " r" + + " INNER JOIN " + recipeUserAccountInfosTable + " ai" + + " ON r.app_id = ai.app_id" + + " AND r.recipe_user_id = ai.recipe_user_id" + + " AND r.recipe_id = ai.recipe_id" + + " AND r.account_info_type = ai.account_info_type" + + " AND r.account_info_value = ai.account_info_value" + + " WHERE r.app_id = ? AND r.recipe_user_id = ? AND ai.primary_user_id IS NULL" + + " ON CONFLICT ON CONSTRAINT " + Utils.getConstraintName(schema, primaryUserTenantsTable, null, "pkey") + + " DO UPDATE SET account_info_type = EXCLUDED.account_info_type" + + " RETURNING primary_user_id, account_info_type"; + + String[] conflict = execute(sqlCon, QUERY, pst -> { + pst.setString(1, userId); // primary_user_id + pst.setString(2, appIdentifier.getAppId()); + pst.setString(3, userId); // recipe_user_id + }, rs -> { + String[] firstConflict = null; + while (rs.next()) { + String returnedPrimaryUserId = rs.getString("primary_user_id"); + String accountInfoType = rs.getString("account_info_type"); + + // Check if the returned primary_user_id is different from the userId + if (!userId.equals(returnedPrimaryUserId)) { + if (firstConflict == null) { + firstConflict = new String[]{returnedPrimaryUserId, accountInfoType}; + } + // Prioritize THIRD_PARTY conflicts + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + return new String[]{returnedPrimaryUserId, accountInfoType}; + } + } + } + return firstConflict; + }); + + // Throw conflict if any row had a different primary_user_id + if (conflict != null) { + assert conflict.length == 2; + String conflictingPrimaryUserId = conflict[0]; + String accountInfoType = conflict[1]; + + String message; + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + message = "This user's email is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + message = "This user's phone number is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + message = "This user's third party login is already associated with another user ID"; + } else { + message = "Account info is already associated with another user ID"; + } + + throw new AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException(conflictingPrimaryUserId, message); + } + + // Update primary_user_id in recipe_user_account_infos to recipe_user_id (making it primary) + // Return both old and new primary_user_id values + String UPDATE_QUERY = "WITH old_values AS (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ?" + + " LIMIT 1" + + ")" + + " UPDATE " + recipeUserAccountInfosTable + + " SET primary_user_id = recipe_user_id" + + " WHERE app_id = ? AND recipe_user_id = ?" + + " RETURNING (SELECT primary_user_id FROM old_values) AS old_primary_user_id, primary_user_id AS new_primary_user_id"; + + String[] result = execute(sqlCon, UPDATE_QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, userId); + }, rs -> { + String[] res = null; + while (rs.next()) { + String oldPrimaryUserId = rs.getString("old_primary_user_id"); + String newPrimaryUserId = rs.getString("new_primary_user_id"); + res = new String[]{oldPrimaryUserId, newPrimaryUserId}; + } + return res; + }); + + if (result == null) { + throw new UnknownUserIdException(); + } + { + String oldPrimaryUserId = result[0]; + String newPrimaryUserId = result[1]; + + if (oldPrimaryUserId != null) { + if (oldPrimaryUserId.equals(newPrimaryUserId)) { + return false; // was already primary + } else { + throw new CannotBecomePrimarySinceRecipeUserIdAlreadyLinkedWithPrimaryUserIdException(oldPrimaryUserId, "This user ID is already linked to another user ID"); + } + } + } + + // all okay + return true; // now became primary + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static CanBecomePrimaryResult checkIfLoginMethodCanBecomePrimary(Start start, AppIdentifier appIdentifier, String recipeUserId) + throws StorageQueryException, UnknownUserIdException { + try { + return start.startTransaction(con -> { + Connection sqlCon = (Connection) con.getConnection(); + + String QUERY = "SELECT primary_user_id FROM " + getConfig(start).getRecipeUserAccountInfosTable() + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1"; + + String[] primaryUserId = execute(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, recipeUserId); + }, rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id")}; + } + return new String[]{}; + }); + + if (primaryUserId.length == 0) { + throw new StorageTransactionLogicException(new UnknownUserIdException()); + } + + assert primaryUserId.length == 1; + + if (primaryUserId[0] != null) { + if (primaryUserId[0].equals(recipeUserId)) { + return CanBecomePrimaryResult.wasAlreadyAPrimeryUserResult(); + } else { + return CanBecomePrimaryResult.linkedWithAnotherPrimaryUserResult(primaryUserId[0]); + } + } + + // now we need to check if the user can become primary by checking if there are conflicting account info + // Get all tenant IDs and account info for this recipe user + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + + // Query to find conflicts: check if any account info of this recipe user + // is already associated with a different primary_user_id in primary_user_tenants + String CONFLICT_QUERY = "SELECT p.primary_user_id, p.account_info_type" + + " FROM " + primaryUserTenantsTable + " p" + + " INNER JOIN " + recipeUserTenantsTable + " r" + + " ON p.app_id = r.app_id" + + " AND p.tenant_id = r.tenant_id" + + " AND p.account_info_type = r.account_info_type" + + " AND p.account_info_value = r.account_info_value" + + " WHERE r.app_id = ?" + + " AND r.recipe_user_id = ?" + + " AND p.primary_user_id != ?" + + " LIMIT 1"; + + String[] conflict = execute(sqlCon, CONFLICT_QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, recipeUserId); + pst.setString(3, recipeUserId); + }, rs -> { + if (rs.next()) { + return new String[]{ + rs.getString("primary_user_id"), + rs.getString("account_info_type") + }; + } + return null; + }); + + if (conflict != null) { + String conflictingPrimaryUserId = conflict[0]; + String accountInfoType = conflict[1]; + + String message; + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + message = "This user's email is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + message = "This user's phone number is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + message = "This user's third party login is already associated with another user ID"; + } else { + message = "Account info is already associated with another primary user"; + } + + return CanBecomePrimaryResult.conflictingAccountInfoResult(conflictingPrimaryUserId, message); + } + + return CanBecomePrimaryResult.okResult(); + }); + } catch (StorageTransactionLogicException e) { + Exception cause = e.actualException; + if (cause instanceof UnknownUserIdException) { + throw (UnknownUserIdException) cause; + } + throw new StorageQueryException(cause); + } + } + + public static CanLinkAccountsResult checkIfLoginMethodsCanBeLinked(Start start, + AppIdentifier appIdentifier, + String _primaryUserId, + String recipeUserId) + throws StorageQueryException, UnknownUserIdException { + try { + + return start.startTransaction(con -> { + + String primaryUserId; + + Connection sqlCon = (Connection) con.getConnection(); + { + String QUERY = "SELECT primary_user_id FROM " + getConfig(start).getRecipeUserAccountInfosTable() + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1"; + + String[] result = execute(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, _primaryUserId); + }, rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id")}; + } + return new String[]{}; + }); + + if (result.length == 0) { + throw new StorageTransactionLogicException(new UnknownUserIdException()); + } + + assert result.length == 1; + + if (result[0] == null) { + return CanLinkAccountsResult.inputUserIsNotPrimaryUserResult(); + } + + primaryUserId = result[0]; + } + + { + String QUERY = "SELECT primary_user_id FROM " + getConfig(start).getRecipeUserAccountInfosTable() + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1"; + + String[] result = execute(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, recipeUserId); + }, rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id")}; + } + return new String[]{}; + }); + + if (result.length == 0) { + throw new StorageTransactionLogicException(new UnknownUserIdException()); + } + + assert result.length == 1; + + if (result[0] != null) { + if (result[0].equals(primaryUserId)) { + return CanLinkAccountsResult.wasAlreadyLinkedToPrimaryUserResult(); + } else { + return CanLinkAccountsResult.recipeUserLinkedToAnotherPrimaryUserResult(result[0]); + } + } + } + + String QUERY = "SELECT primary_user_id, account_info_type " + + "FROM " + getConfig(start).getPrimaryUserTenantsTable() + " " + + "WHERE app_id = ? AND ((account_info_type, account_info_value) IN (" + + " (SELECT account_info_type, account_info_value " + + " FROM " + getConfig(start).getPrimaryUserTenantsTable() + " " + + " WHERE app_id = ? AND primary_user_id = ?) " + + " UNION " + + " (SELECT account_info_type, account_info_value " + + " FROM " + getConfig(start).getRecipeUserAccountInfosTable() + " " + + " WHERE app_id = ? AND recipe_user_id = ?)" + + ")) AND ((tenant_id) IN (" + + " (SELECT tenant_id " + + " FROM " + getConfig(start).getPrimaryUserTenantsTable() + " " + + " WHERE app_id = ? AND primary_user_id = ?) " + + " UNION " + + " (SELECT tenant_id " + + " FROM " + getConfig(start).getRecipeUserTenantsTable() + " " + + " WHERE app_id = ? AND recipe_user_id = ?)" + + ")) AND primary_user_id != ? LIMIT 1;"; + + String[] result = execute(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); // primary_user_tenants.app_id (main) + pst.setString(2, appIdentifier.getAppId()); // subquery 1: primary_user_tenants.app_id + pst.setString(3, primaryUserId); // subquery 1: primary_user_tenants.primary_user_id + pst.setString(4, appIdentifier.getAppId()); // subquery 2: recipe_user_account_infos.app_id + pst.setString(5, recipeUserId); // subquery 2: recipe_user_account_infos.recipe_user_id + pst.setString(6, appIdentifier.getAppId()); // tenant from primary_user_tenants + pst.setString(7, primaryUserId); // tenant from primary_user_tenants.primary_user_id + pst.setString(8, appIdentifier.getAppId()); // tenant from recipe_user_tenants.app_id + pst.setString(9, recipeUserId); // tenant from recipe_user_tenants.recipe_user_id + pst.setString(10, primaryUserId); // primary user id that's not matching + }, rs -> { + if (rs.next()) { + // Return conflicting primary_user_id and account_info_type + return new String[]{rs.getString("primary_user_id"), rs.getString("account_info_type")}; + } + return null; + }); + + if (result != null && !result[0].equals(primaryUserId)) { + String conflictingPrimaryUserId = result[0]; + String accountInfoType = result[1]; + + String message; + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + message = "This user's email is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + message = "This user's phone number is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + message = "This user's third party login is already associated with another user ID"; + } else { + message = "Account info is already associated with another primary user"; + } + + return CanLinkAccountsResult.notOkResult(conflictingPrimaryUserId, message); + } + + return CanLinkAccountsResult.okResult(); + }); + } catch (StorageTransactionLogicException e) { + Exception cause = e.actualException; + if (cause instanceof UnknownUserIdException) { + throw (UnknownUserIdException) cause; + } + throw new StorageQueryException(cause); + } + + + } + + public static boolean reserveAccountInfoForLinking_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, + String recipeUserId, String _primaryUserId) + throws StorageQueryException, UnknownUserIdException, + InputUserIdIsNotAPrimaryUserException, CannotLinkSinceRecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException, + AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException { + + try { + String schema = Config.getConfig(start).getTableSchema(); + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + + // Step 1: Fetch the actual primaryUserId for _primaryUserId + String primaryUserId; + String fetchPrimaryUserIdQuery = "SELECT primary_user_id FROM " + recipeUserAccountInfosTable + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1"; + String[] primaryUserIds = execute(sqlCon, fetchPrimaryUserIdQuery, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, _primaryUserId); + }, rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id")}; + } + return null; + }); + + if (primaryUserIds == null) { + throw new UnknownUserIdException(); + } + if (primaryUserIds[0] == null) { + // if the mapping doesn't show this as a primary user, it means this user is not a primary user + throw new InputUserIdIsNotAPrimaryUserException(_primaryUserId); + } + + primaryUserId = primaryUserIds[0]; + + // Ensure no linking to same user in parallel + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock(sqlCon, appIdentifier.getAppId() + "~" + primaryUserId); + + // Step 2: Find all target tenant_ids to write for (union of tenants for the primary user and for the recipe user) + // and find all (account_info_type, account_info_value) for this user (union from both primary and recipe user) + // The select/join/insert operations will now use the retrieved primaryUserId value directly + + String QUERY = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT ?, all_tenants.tenant_id, all_accounts.account_info_type, all_accounts.account_info_value, ?" + + " FROM (" + + " SELECT tenant_id FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id = ?" + + " UNION" + + " SELECT tenant_id FROM " + recipeUserTenantsTable + " WHERE app_id = ? AND recipe_user_id = ?" + + " ) all_tenants CROSS JOIN (" + + " SELECT account_info_type, account_info_value FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id = ?" + + " UNION" + + " SELECT account_info_type, account_info_value FROM " + recipeUserAccountInfosTable + " WHERE app_id = ? AND recipe_user_id = ? AND primary_user_id is NULL" + + " ) all_accounts" + + " ON CONFLICT ON CONSTRAINT " + Utils.getConstraintName(schema, primaryUserTenantsTable, null, "pkey") + + " DO UPDATE SET account_info_type = EXCLUDED.account_info_type" + + " RETURNING primary_user_id, account_info_type"; + + String[] conflict = execute(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); // app_id for INSERT + pst.setString(2, primaryUserId); // primary_user_id for INSERT + + pst.setString(3, appIdentifier.getAppId()); // tenant subquery 1: primary_user_tenants.app_id + pst.setString(4, primaryUserId); // tenant subquery 1: primary_user_id + pst.setString(5, appIdentifier.getAppId()); // tenant subquery 2: recipe_user_tenants.app_id + pst.setString(6, recipeUserId); // tenant subquery 2: recipe_user_tenants.recipe_user_id + + pst.setString(7, appIdentifier.getAppId()); // account subquery 1: primary_user_tenants.app_id + pst.setString(8, primaryUserId); // account subquery 1: primary_user_id + pst.setString(9, appIdentifier.getAppId()); // account subquery 2: recipe_user_account_infos.app_id + pst.setString(10, recipeUserId); // account subquery 2: recipe_user_account_infos.recipe_user_id + }, rs -> { + String[] firstConflict = null; + while (rs.next()) { + String returnedPrimaryUserId = rs.getString("primary_user_id"); + String accountInfoType = rs.getString("account_info_type"); + + // Check if the returned primary_user_id is different from the expected primaryUserId + if (!primaryUserId.equals(returnedPrimaryUserId)) { + if (firstConflict == null) { + firstConflict = new String[]{returnedPrimaryUserId, accountInfoType}; + } + // Prioritize THIRD_PARTY conflicts + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + return new String[]{returnedPrimaryUserId, accountInfoType}; + } + } + } + return firstConflict; + }); + + // Throw conflict if any row had a different primary_user_id + if (conflict != null && conflict[0] != null) { + String conflictingPrimaryUserId = conflict[0].trim(); + String accountInfoType = conflict[1]; + + String message; + if (ACCOUNT_INFO_TYPE.EMAIL.toString().equals(accountInfoType)) { + message = "This user's email is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString().equals(accountInfoType)) { + message = "This user's phone number is already associated with another user ID"; + } else if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + message = "This user's third party login is already associated with another user ID"; + } else { + message = "Account info is already associated with another user ID"; + } + + throw new AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException(conflictingPrimaryUserId, message); + } + + // Update primary_user_id in recipe_user_account_infos to link the recipe user to the primary user + String UPDATE_QUERY = "WITH old_values AS (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ?" + + " LIMIT 1" + + ")" + + " UPDATE " + recipeUserAccountInfosTable + + " SET primary_user_id = ?" + + " WHERE app_id = ? AND recipe_user_id = ?" + + " RETURNING (SELECT primary_user_id FROM old_values) AS old_primary_user_id, primary_user_id AS new_primary_user_id"; + + String[] result = execute(sqlCon, UPDATE_QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, recipeUserId); + pst.setString(3, primaryUserId); + pst.setString(4, appIdentifier.getAppId()); + pst.setString(5, recipeUserId); + }, rs -> { + String[] res = null; + while (rs.next()) { + String oldPrimaryUserId = rs.getString("old_primary_user_id"); + String newPrimaryUserId = rs.getString("new_primary_user_id"); + res = new String[]{oldPrimaryUserId, newPrimaryUserId}; + } + return res; + }); + + if (result == null) { + throw new UnknownUserIdException(); + } + + { + String oldPrimaryUserId = result[0]; + String newPrimaryUserId = result[1]; + + // If newPrimaryUserId is NULL, it means something went wrong + if (newPrimaryUserId == null) { + throw new InputUserIdIsNotAPrimaryUserException(primaryUserId); + } + + if (oldPrimaryUserId != null) { + if (oldPrimaryUserId.equals(newPrimaryUserId)) { + return false; // was already linked to this primary user + } else { + // Fetch the recipe user info to include in the exception + AuthRecipeUserInfo recipeUserInfo = GeneralQueries.getPrimaryUserInfoForUserId_Transaction( + start, sqlCon, appIdentifier, recipeUserId); + if (recipeUserInfo == null) { + throw new UnknownUserIdException(); + } + throw new CannotLinkSinceRecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException( + recipeUserInfo); + } + } + } + + // all okay + return true; + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void addTenantIdToRecipeUser_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) + throws StorageQueryException, DuplicateEmailException, DuplicateThirdPartyUserException, DuplicatePhoneNumberException { + String schema = Config.getConfig(start).getTableSchema(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + + String QUERY = "INSERT INTO " + recipeUserTenantsTable + + " (app_id, recipe_user_id, tenant_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value)" + + " SELECT DISTINCT r.app_id, r.recipe_user_id, ?, r.recipe_id, r.account_info_type, r.third_party_id, r.third_party_user_id, r.account_info_value" + + " FROM " + recipeUserAccountInfosTable + " r" + + " WHERE r.app_id = ? AND r.recipe_user_id = ?" + + " ON CONFLICT ON CONSTRAINT " + Utils.getConstraintName(schema, recipeUserTenantsTable, null, "pkey") + + " DO UPDATE SET account_info_type = EXCLUDED.account_info_type " + + " RETURNING recipe_user_id, account_info_type"; + + try { + String conflictAccountInfoType = execute(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getTenantId()); + pst.setString(2, tenantIdentifier.getAppId()); + pst.setString(3, userId); + }, rs -> { + String firstConflictType = null; + while (rs.next()) { + String returnedRecipeUserId = rs.getString("recipe_user_id"); + String accountInfoType = rs.getString("account_info_type"); + + // Check if the returned recipe_user_id is different from the userId + if (!userId.equals(returnedRecipeUserId)) { + if (firstConflictType == null) { + firstConflictType = accountInfoType; + } + // Prioritize THIRD_PARTY conflicts + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + return accountInfoType; + } + } + } + return firstConflictType; + }); + + // Throw conflict if any row had a different recipe_user_id + throwRecipeUserTenantsConflict(conflictAccountInfoType, false); + } catch (EmailChangeNotAllowedException | PhoneNumberChangeNotAllowedException e) { + throw new IllegalStateException("should never happen", e); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void addTenantIdToPrimaryUser_Transaction(Start start, TransactionConnection con, TenantIdentifier tenantIdentifier, String supertokensUserId) + throws StorageQueryException, + AnotherPrimaryUserWithPhoneNumberAlreadyExistsException, + AnotherPrimaryUserWithEmailAlreadyExistsException, + AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException { + Connection sqlCon = (Connection) con.getConnection(); + String schema = Config.getConfig(start).getTableSchema(); + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + + String QUERY = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT rac.app_id, ?, rac.account_info_type, rac.account_info_value, rac.primary_user_id" + + " FROM " + recipeUserAccountInfosTable + " rac" + + " WHERE rac.app_id = ? AND rac.recipe_user_id = ?" + + " ON CONFLICT ON CONSTRAINT " + Utils.getConstraintName(schema, primaryUserTenantsTable, null, "pkey") + + " DO UPDATE SET account_info_type = EXCLUDED.account_info_type " + + " RETURNING primary_user_id, account_info_type"; + + try { + String[] conflict = execute(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getTenantId()); + pst.setString(2, tenantIdentifier.getAppId()); + pst.setString(3, supertokensUserId); + }, rs -> { + String[] firstConflict = null; + while (rs.next()) { + String returnedPrimaryUserId = rs.getString("primary_user_id"); + String accountInfoType = rs.getString("account_info_type"); + + // Check if the returned primary_user_id is different from the supertokensUserId + if (!supertokensUserId.equals(returnedPrimaryUserId)) { + if (firstConflict == null) { + firstConflict = new String[]{returnedPrimaryUserId, accountInfoType}; + } + // Prioritize THIRD_PARTY conflicts + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + return new String[]{returnedPrimaryUserId, accountInfoType}; + } + } + } + return firstConflict; + }); + + // Throw conflict if any row had a different primary_user_id + throwPrimaryUserTenantsConflict(conflict); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void removeAccountInfoForRecipeUserWhileRemovingTenant_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) throws StorageQueryException { + try { + String QUERY = "DELETE FROM " + getConfig(start).getRecipeUserTenantsTable() + + " WHERE app_id = ? AND tenant_id = ? AND recipe_user_id = ?"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, userId); + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void removeAccountInfoReservationForPrimaryUserWhileRemovingTenant_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) throws StorageQueryException { + try { + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + // This query removes rows from the primary_user_tenants table for the given primary user (identified by the passed-in userId), + // but only for those tenants that the user is no longer associated with after a tenant removal operation. + // It does so by: + // 1. Identifying the primary_user_id linked to the given recipe_user (by userId). + // 2. Deleting only those primary_user_tenants rows (for this app and primary_user_id) whose tenant_id is NOT present + // in the list of tenants remaining for any of the primary user's linked recipe users, + // except for the tenant/user combination being removed (i.e., tenant_id != removed tenant). + // 3. Effectively, this ensures that account info reservations in primary_user_tenants only remain on tenants + // where the primary user (or any linked user) is still active after this tenant of user is removed. + String QUERY = "DELETE FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id IN (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + " WHERE recipe_user_id = ? LIMIT 1" + + " ) AND (tenant_id) NOT IN (" + + " SELECT DISTINCT tenant_id" + + " FROM " + recipeUserTenantsTable + + " WHERE recipe_user_id IN (" + + " SELECT recipe_user_id" + + " FROM " + recipeUserAccountInfosTable + + " WHERE primary_user_id IN (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + + " WHERE recipe_user_id = ? LIMIT 1" + + " ) AND ((recipe_user_id = ? AND tenant_id != ?) OR recipe_user_id != ?)" + + " )" + + " )"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, userId); + pst.setString(4, userId); + pst.setString(5, tenantIdentifier.getTenantId()); + pst.setString(6, userId); + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void removeAccountInfoReservationForPrimaryUserForUnlinking_Transaction(Start start, Connection sqlCon, AppIdentifier tenantIdentifier, String userId) throws StorageQueryException { + try { + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + // This query removes rows from the primary_user_tenants table for the given primary user (identified by the passed-in userId), + // but only for those account info and tenant combinations that the user is no longer associated with after an unlinking operation. + // It does so by: + // 1. Identifying the primary_user_id linked to the given recipe_user (by userId). + // 2. Deleting only those primary_user_tenants rows (for this app and primary_user_id) where: + // a) The (account_info_type, account_info_value) combination is NOT present in any other linked recipe user's + // recipe_user_tenants, OR + // b) The tenant_id is NOT present in any other linked recipe user's recipe_user_tenants. + // 3. Effectively, this ensures that account info reservations in primary_user_tenants only remain where + // the primary user (or any other linked user) still has that account info or tenant after this user is unlinked. + String QUERY = "DELETE FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id IN (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1" + + " ) AND (" + + " (account_info_type, account_info_value) NOT IN (" + + " SELECT DISTINCT account_info_type, account_info_value" + + " FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND primary_user_id IN (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1" + + " ) AND recipe_user_id <> ?" + + " )" + + " OR tenant_id NOT IN (" + + " SELECT DISTINCT tenant_id" + + " FROM " + recipeUserTenantsTable + + " WHERE app_id = ? AND recipe_user_id IN (" + + " SELECT recipe_user_id" + + " FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND primary_user_id IN (" + + " SELECT primary_user_id FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1" + + " ) AND recipe_user_id <> ?" + + " )" + + " )" + + " )"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); // WHERE app_id = ? + pst.setString(2, tenantIdentifier.getAppId()); // SELECT ... WHERE app_id = ? + pst.setString(3, userId); // ... AND recipe_user_id = ? + pst.setString(4, tenantIdentifier.getAppId()); // WHERE app_id = ? (NOT IN clause) + pst.setString(5, tenantIdentifier.getAppId()); // SELECT ... WHERE app_id = ? (nested) + pst.setString(6, userId); // ... AND recipe_user_id = ? (nested) + pst.setString(7, userId); // ... AND recipe_user_id <> ? + pst.setString(8, tenantIdentifier.getAppId()); // WHERE app_id = ? (tenant_id NOT IN) + pst.setString(9, tenantIdentifier.getAppId()); // WHERE app_id = ? (nested in tenant_id NOT IN) + pst.setString(10, tenantIdentifier.getAppId()); // SELECT ... WHERE app_id = ? (deeply nested) + pst.setString(11, userId); // ... AND recipe_user_id = ? (deeply nested) + pst.setString(12, userId); // ... AND recipe_user_id <> ? + }); + + // Update primary_user_id to NULL in recipe_user_account_infos when unlinking + String UPDATE_QUERY = "UPDATE " + recipeUserAccountInfosTable + + " SET primary_user_id = NULL" + + " WHERE app_id = ? AND recipe_user_id = ?"; + + update(sqlCon, UPDATE_QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void removeAccountInfoReservationsForDeletingUser_Transaction(Start start, TransactionConnection con, + AppIdentifier appIdentifier, String userId) + throws StorageQueryException { + try { + Connection sqlCon = (Connection) con.getConnection(); + + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + + removeAccountInfoReservationForPrimaryUserForUnlinking_Transaction(start, sqlCon, appIdentifier, userId); + + { + String recipeUserTenantsDelete = "DELETE FROM " + recipeUserTenantsTable + + " WHERE app_id = ? AND recipe_user_id = ?"; + update(sqlCon, recipeUserTenantsDelete, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + }); + } + + { + String recipeUserTenantsDelete = "DELETE FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ?"; + update(sqlCon, recipeUserTenantsDelete, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + }); + } + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void updateAccountInfo_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, String userId, ACCOUNT_INFO_TYPE accountInfoType, String accountInfoValue) + throws + EmailChangeNotAllowedException, PhoneNumberChangeNotAllowedException, StorageQueryException, + DuplicateEmailException, DuplicatePhoneNumberException, DuplicateThirdPartyUserException, + UnknownUserIdException { + if (!ACCOUNT_INFO_TYPE.EMAIL.equals(accountInfoType) && !ACCOUNT_INFO_TYPE.PHONE_NUMBER.equals(accountInfoType)) { + // Third party account info updates are not allowed via this function. + throw new IllegalArgumentException( + "updateAccountInfo_Transaction should only be called with accountInfoType EMAIL or PHONE_NUMBER"); + } + + String primaryUserId = null; + + try { + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + String recipeUserAccountInfosTable = getConfig(start).getRecipeUserAccountInfosTable(); + + // Find primary user ID and whether this recipe user is linked (or itself is a primary user). + // Query recipe_user_tenants to get primary_user_id. If primary_user_id IS NOT NULL, the user is linked or primary. + // If primary_user_id = recipe_user_id, the user is primary. Otherwise, it's linked to that primary. + String[] primaryUserIds = execute(sqlCon, + "SELECT DISTINCT primary_user_id FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ?", + pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + }, + rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id")}; + } + return null; + }); + if (primaryUserIds == null) { + throw new UnknownUserIdException(); + } + + primaryUserId = primaryUserIds[0]; + + // 1. Delete from primary_user_tenants to remove old account info if not contributed by any other linked user. + if (primaryUserId != null) { + final String primaryUserIdFinal = primaryUserId; + String QUERY_1 = "DELETE FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id = ? AND account_info_type = ? AND account_info_value NOT IN (" + + " SELECT account_info_value" + + " FROM " + recipeUserTenantsTable + + " WHERE recipe_user_id IN (" + + " SELECT recipe_user_id" + + " FROM " + recipeUserAccountInfosTable + + " WHERE primary_user_id = ? AND recipe_user_id != ?" + + " )" + + " )"; + + update(sqlCon, QUERY_1, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserIdFinal); + pst.setString(3, accountInfoType.toString()); + pst.setString(4, primaryUserIdFinal); + pst.setString(5, userId); + }); + } + + // 2. Update account info value in recipe_user_tenants (across all tenants for this recipe user). + // If accountInfoValue is null, delete the rows instead. + if (accountInfoValue == null) { + { + String QUERY_2_DELETE = "DELETE FROM " + recipeUserTenantsTable + + " WHERE app_id = ? AND recipe_user_id = ? AND account_info_type = ?"; + update(sqlCon, QUERY_2_DELETE, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, accountInfoType.toString()); + }); + } + { + String QUERY_2_DELETE = "DELETE FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ? AND account_info_type = ?"; + update(sqlCon, QUERY_2_DELETE, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, accountInfoType.toString()); + }); + } + } else { + { + // Insert accountInfoType and accountInfoValue for all tenants that match app_id and user_id + String QUERY_2_INSERT = "INSERT INTO " + recipeUserTenantsTable + + " (app_id, recipe_user_id, tenant_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value)" + + " SELECT DISTINCT r.app_id, r.recipe_user_id, r.tenant_id, r.recipe_id, ?, r.third_party_id, r.third_party_user_id, ?" + + " FROM " + recipeUserTenantsTable + " r" + + " WHERE r.app_id = ? AND r.recipe_user_id = ?"; + update(sqlCon, QUERY_2_INSERT, pst -> { + pst.setString(1, accountInfoType.toString()); + pst.setString(2, accountInfoValue); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, userId); + }); + + // Delete records that match app_id, user_id and account_info_type based on current account_info_value in recipe_user_account_infos + String QUERY_2_DELETE = "DELETE FROM " + recipeUserTenantsTable + + " WHERE app_id = ? AND recipe_user_id = ? AND account_info_type = ?" + + " AND account_info_value != ?"; + update(sqlCon, QUERY_2_DELETE, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, accountInfoType.toString()); + pst.setString(4, accountInfoValue); + }); + } + { + String schema = Config.getConfig(start).getTableSchema(); + // Upsert into recipe_user_account_infos + String QUERY_2_UPSERT = "INSERT INTO " + recipeUserAccountInfosTable + + " (app_id, recipe_user_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value, primary_user_id)" + + " SELECT ?, ?, recipe_id, ?, third_party_id, third_party_user_id, ?, primary_user_id" + + " FROM " + recipeUserAccountInfosTable + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1" + + " ON CONFLICT ON CONSTRAINT " + Utils.getConstraintName(schema, recipeUserAccountInfosTable, null, "pkey") + + " DO UPDATE SET account_info_value = EXCLUDED.account_info_value"; + update(sqlCon, QUERY_2_UPSERT, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, accountInfoType.toString()); + pst.setString(4, accountInfoValue); + pst.setString(5, appIdentifier.getAppId()); + pst.setString(6, userId); + }); + } + } + + // 3. Insert into primary_user_tenants to add new account info if not already reserved by same primary. + if (accountInfoValue != null && primaryUserId != null) { + final String primaryUserIdFinal = primaryUserId; + String QUERY_3 = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT DISTINCT r.app_id, r.tenant_id, r.account_info_type, r.account_info_value, ?" + + " FROM " + recipeUserTenantsTable + " r" + + " WHERE r.app_id = ? AND r.recipe_user_id = ?" + + " AND r.account_info_type = ? AND r.account_info_value = ?" + + " AND NOT EXISTS (" + + " SELECT 1 FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = r.app_id" + + " AND p.tenant_id = r.tenant_id" + + " AND p.account_info_type = r.account_info_type" + + " AND p.account_info_value = r.account_info_value" + + " AND p.primary_user_id = ?" + + " )"; + + update(sqlCon, QUERY_3, pst -> { + pst.setString(1, primaryUserIdFinal); + pst.setString(2, appIdentifier.getAppId()); + pst.setString(3, userId); + pst.setString(4, accountInfoType.toString()); + pst.setString(5, accountInfoValue); + pst.setString(6, primaryUserIdFinal); + }); + } + } catch (SQLException e) { + if (e instanceof PSQLException) { + ServerErrorMessage serverMessage = ((PSQLException) e).getServerErrorMessage(); + boolean isRecipeUserTenantsPk = isPrimaryKeyError(serverMessage, getConfig(start).getRecipeUserTenantsTable()); + boolean isPrimaryUserTenantsPk = isPrimaryKeyError(serverMessage, getConfig(start).getPrimaryUserTenantsTable()); + if (isPrimaryUserTenantsPk) { + throwAccountInfoChangeNotAllowed(accountInfoType); + } else if (isRecipeUserTenantsPk) { + throwRecipeUserTenantsConflict(accountInfoType.toString(), primaryUserId != null); + } + } + throw new StorageQueryException(e); + } + } + + public static void addRecipeUserAccountInfoToBatch(List recipeUserAccountInfoBatch, AppIdentifier appIdentifier, String recipeUserId, String recipeId, ACCOUNT_INFO_TYPE accountInfoType, String thirdPartyId, String thirdPartyUserId, String accountInfoValue, String primaryUserId) { + recipeUserAccountInfoBatch.add(pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, recipeUserId); + pst.setString(3, recipeId); + pst.setString(4, accountInfoType.toString()); + pst.setString(5, thirdPartyId); + pst.setString(6, thirdPartyUserId); + pst.setString(7, accountInfoValue); + pst.setString(8, primaryUserId); + }); + } + + public static void addRecipeUserTenantsToBatch(List recipeUserAccountInfoBatch, AppIdentifier appIdentifier, String recipeUserId, String recipeId, ACCOUNT_INFO_TYPE accountInfoType, String thirdPartyId, String thirdPartyUserId, String accountInfoValue, + List recipeUserTenantIds) { + if (thirdPartyId.length() > 28) { + System.out.println(thirdPartyId); + } + for (String tenantId : recipeUserTenantIds) { + recipeUserAccountInfoBatch.add(pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, recipeUserId); + pst.setString(3, tenantId); + pst.setString(4, recipeId); + pst.setString(5, accountInfoType.toString()); + pst.setString(6, thirdPartyId); + pst.setString(7, thirdPartyUserId); + pst.setString(8, accountInfoValue); + }); + } + } + + public static String getRecipeUserAccountInfoBatchQuery (Start start) { + return "INSERT INTO " + getConfig(start).getRecipeUserAccountInfosTable() + + "(app_id, recipe_user_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value, primary_user_id)" + + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; + } + + public static String getRecipeUserTenantBatchQuery (Start start) { + return "INSERT INTO " + getConfig(start).getRecipeUserTenantsTable() + + "(app_id, recipe_user_id, tenant_id, recipe_id, account_info_type, third_party_id, third_party_user_id, account_info_value)" + + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; + } + + public static String getPrimaryUserTenantBatchQuery (Start start) { + return "INSERT INTO " + getConfig(start).getPrimaryUserTenantsTable() + + "(app_id, tenant_id, primary_user_id, account_info_type, account_info_value)" + + " VALUES(?, ?, ?, ?, ?)"; + } + + public static void reservePrimaryUserAccountInfos_Transaction(Start start, TransactionConnection con, List primaryUsers) + throws SQLException, StorageQueryException { + String QUERY = getPrimaryUserTenantBatchQuery(start); + Connection sqlCon = (Connection) con.getConnection(); + List primaryUserTenantSetters = new ArrayList<>(); + + for (var user : primaryUsers) { + for (var accountInfo : user.accountInfos) { + for (String tenantId : user.tenantIds) { + primaryUserTenantSetters.add(pst -> { + pst.setString(1, user.appIdentifier.getAppId()); + pst.setString(2, tenantId); + pst.setString(3, user.primaryUserId); + pst.setString(4, accountInfo.type.toString()); + pst.setString(5, accountInfo.value); + }); + } + } + } + + executeBatch(sqlCon, QUERY, primaryUserTenantSetters); + } +} + + diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java index a88aac19..f1a16f8a 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java @@ -16,31 +16,39 @@ package io.supertokens.storage.postgresql.queries; +import static java.lang.System.currentTimeMillis; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static io.supertokens.pluginInterface.RECIPE_ID.EMAIL_PASSWORD; import io.supertokens.pluginInterface.RowMapper; +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; +import io.supertokens.pluginInterface.authRecipe.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.emailpassword.EmailPasswordImportUser; import io.supertokens.pluginInterface.emailpassword.PasswordResetTokenInfo; -import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.storage.postgresql.PreparedStatementValueSetter; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.executeBatch; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; -import io.supertokens.storage.postgresql.utils.Utils; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.stream.Collectors; - -import static io.supertokens.pluginInterface.RECIPE_ID.EMAIL_PASSWORD; -import static io.supertokens.storage.postgresql.QueryExecutorTemplate.*; import static io.supertokens.storage.postgresql.config.Config.getConfig; -import static java.lang.System.currentTimeMillis; +import io.supertokens.storage.postgresql.utils.Utils; public class EmailPasswordQueries { static String getQueryToCreateUsersTable(Start start) { @@ -134,7 +142,7 @@ public static void deleteExpiredPasswordResetTokens(Start start) throws SQLExcep public static void updateUsersPassword_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String userId, String newPassword) - throws SQLException, StorageQueryException { + throws SQLException { String QUERY = "UPDATE " + getConfig(start).getEmailPasswordUsersTable() + " SET password_hash = ? WHERE app_id = ? AND user_id = ?"; @@ -147,7 +155,7 @@ public static void updateUsersPassword_Transaction(Start start, Connection con, public static void updateUsersEmail_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String userId, String newEmail) - throws SQLException, StorageQueryException { + throws SQLException { { String QUERY = "UPDATE " + getConfig(start).getEmailPasswordUsersTable() + " SET email = ? WHERE app_id = ? AND user_id = ?"; @@ -172,7 +180,7 @@ public static void updateUsersEmail_Transaction(Start start, Connection con, App public static void deleteAllPasswordResetTokensForUser_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String userId) - throws SQLException, StorageQueryException { + throws SQLException { String QUERY = "DELETE FROM " + getConfig(start).getPasswordResetTokensTable() + " WHERE app_id = ? AND user_id = ?"; @@ -309,6 +317,11 @@ public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIden }); } + { // recipe_user_tenants + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, userId, + EMAIL_PASSWORD.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", email); + } + { // emailpassword_users String QUERY = "INSERT INTO " + getConfig(start).getEmailPasswordUsersTable() + "(app_id, user_id, email, password_hash, time_joined)" + " VALUES(?, ?, ?, ?, ?)"; @@ -345,16 +358,16 @@ public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIden }); } - public static void signUpMultipleForBulkImport_Transaction(Start start, Connection sqlCon, List usersToSignUp) + public static void importUsers_Transaction(Start start, Connection sqlCon, List usersToSignUp) throws StorageQueryException, StorageTransactionLogicException { try { String app_id_to_user_id_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)"; String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() + - "(app_id, tenant_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, " + + "(app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, " + "primary_or_recipe_user_time_joined)" + - " VALUES(?, ?, ?, ?, ?, ?, ?)"; + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; String emailpassword_users_QUERY = "INSERT INTO " + getConfig(start).getEmailPasswordUsersTable() + "(app_id, user_id, email, password_hash, time_joined)" + " VALUES(?, ?, ?, ?, ?)"; @@ -363,6 +376,9 @@ public static void signUpMultipleForBulkImport_Transaction(Start start, Connecti "INSERT INTO " + getConfig(start).getEmailPasswordUserToTenantTable() + "(app_id, tenant_id, user_id, email)" + " VALUES(?, ?, ?, ?)"; + List recipeUserAccountInfoBatch = new ArrayList<>(); + List recipeUserTenantsBatch = new ArrayList<>(); + List appIdToUserIdSetters = new ArrayList<>(); List allAuthRecipeUsersSetters = new ArrayList<>(); List emailPasswordUsersSetters = new ArrayList<>(); @@ -370,41 +386,58 @@ public static void signUpMultipleForBulkImport_Transaction(Start start, Connecti for (EmailPasswordImportUser user : usersToSignUp) { String userId = user.userId; - TenantIdentifier tenantIdentifier = user.tenantIdentifier; + String appId = user.appIdentifier.getAppId(); + String primaryOrRecipeUserId = user.primaryUserId != null ? user.primaryUserId : user.userId; + boolean isLinkedOrIsPrimaryUser = user.primaryUserId != null; + + // Recipe Account Info + AccountInfoQueries.addRecipeUserAccountInfoToBatch(recipeUserAccountInfoBatch, user.appIdentifier, user.userId, EMAIL_PASSWORD.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", user.email, isLinkedOrIsPrimaryUser ? primaryOrRecipeUserId : null); + + // Recipe User Tenants + AccountInfoQueries.addRecipeUserTenantsToBatch(recipeUserTenantsBatch, user.appIdentifier, user.userId, EMAIL_PASSWORD.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", user.email, user.recipeUserTenantIds); + appIdToUserIdSetters.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(1, appId); pst.setString(2, userId); - pst.setString(3, userId); - pst.setString(4, EMAIL_PASSWORD.toString()); - }); - - allAuthRecipeUsersSetters.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); - pst.setString(2, tenantIdentifier.getTenantId()); - pst.setString(3, userId); - pst.setString(4, userId); + pst.setString(3, primaryOrRecipeUserId); + pst.setBoolean(4, isLinkedOrIsPrimaryUser); pst.setString(5, EMAIL_PASSWORD.toString()); - pst.setLong(6, user.timeJoinedMSSinceEpoch); - pst.setLong(7, user.timeJoinedMSSinceEpoch); }); emailPasswordUsersSetters.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(1, appId); pst.setString(2, userId); pst.setString(3, user.email); pst.setString(4, user.passwordHash); pst.setLong(5, user.timeJoinedMSSinceEpoch); }); - emailPasswordUsersToTenantSetters.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); - pst.setString(2, tenantIdentifier.getTenantId()); - pst.setString(3, userId); - pst.setString(4, user.email); - }); + // Generate entries for all recipe user tenant IDs + for (String tenantId : user.recipeUserTenantIds) { + allAuthRecipeUsersSetters.add(pst -> { + pst.setString(1, appId); + pst.setString(2, tenantId); + pst.setString(3, userId); + pst.setString(4, primaryOrRecipeUserId); + pst.setBoolean(5, isLinkedOrIsPrimaryUser); + pst.setString(6, EMAIL_PASSWORD.toString()); + pst.setLong(7, user.timeJoinedMSSinceEpoch); + pst.setLong(8, user.timeJoinedMSSinceEpoch); + }); + + emailPasswordUsersToTenantSetters.add(pst -> { + pst.setString(1, appId); + pst.setString(2, tenantId); + pst.setString(3, userId); + pst.setString(4, user.email); + }); + } } + executeBatch(sqlCon, AccountInfoQueries.getRecipeUserAccountInfoBatchQuery(start), recipeUserAccountInfoBatch); + executeBatch(sqlCon, AccountInfoQueries.getRecipeUserTenantBatchQuery(start), recipeUserTenantsBatch); + executeBatch(sqlCon, app_id_to_user_id_QUERY, appIdToUserIdSetters); executeBatch(sqlCon, all_auth_recipe_users_QUERY, allAuthRecipeUsersSetters); executeBatch(sqlCon, emailpassword_users_QUERY, emailPasswordUsersSetters); @@ -417,7 +450,7 @@ public static void signUpMultipleForBulkImport_Transaction(Start start, Connecti public static void deleteUser_Transaction(Connection sqlCon, Start start, AppIdentifier appIdentifier, String userId, boolean deleteUserIdMappingToo) - throws StorageQueryException, SQLException { + throws SQLException { if (deleteUserIdMappingToo) { String QUERY = "DELETE FROM " + getConfig(start).getAppIdToUserIdTable() + " WHERE app_id = ? AND user_id = ?"; @@ -540,48 +573,6 @@ public static List getUsersInfoUsingIdList_Transaction(Start start, return Collections.emptyList(); } - public static String lockEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - String email) - throws StorageQueryException, SQLException { - String QUERY = "SELECT user_id FROM " + getConfig(start).getEmailPasswordUsersTable() + - " WHERE app_id = ? AND email = ? FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, email); - }, result -> { - if (result.next()) { - return result.getString("user_id"); - } - return null; - }); - } - - public static List lockEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List emails) - throws StorageQueryException, SQLException { - if(emails == null || emails.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT user_id FROM " + getConfig(start).getEmailPasswordUsersTable() + - " WHERE app_id = ? AND email IN (" + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + ") FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < emails.size(); i++) { - pst.setString(2 + i, emails.get(i)); - } - }, result -> { - List results = new ArrayList<>(); - while (result.next()) { - results.add(result.getString("user_id")); - } - return results; - }); - } - public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { @@ -603,55 +594,6 @@ public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier te }); } - public static List getPrimaryUserIdsUsingEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - String email) - throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getEmailPasswordUsersTable() + " AS ep" + - " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS all_users" + - " ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" + - " WHERE ep.app_id = ? AND ep.email = ?"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, email); - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - - public static List getPrimaryUserIdsUsingMultipleEmails_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List emails) - throws StorageQueryException, SQLException { - if(emails == null || emails.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getEmailPasswordUsersTable() + " AS ep" + - " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS all_users" + - " ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" + - " WHERE ep.app_id = ? AND ep.email IN ( " + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + " )"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < emails.size(); i++) { - pst.setString(2+i, emails.get(i)); - } - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - public static boolean addUserIdToTenant_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) throws SQLException, StorageQueryException, UnknownUserIdException { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java index 679d0936..ec865f8d 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -724,6 +724,32 @@ public static void createTablesIfNotExists(Start start, Connection con) throws S update(con, SAMLQueries.getQueryToCreateSAMLClaimsExpiresAtIndex(start), NO_OP_SETTER); } + if (!doesTableExists(start, con, Config.getConfig(start).getRecipeUserAccountInfosTable())) { + getInstance(start).addState(CREATING_NEW_TABLE, null); + update(con, AccountInfoQueries.getQueryToCreateRecipeUserAccountInfosTable(start), NO_OP_SETTER); + + // indexes + // TODO + } + + if (!doesTableExists(start, con, Config.getConfig(start).getRecipeUserTenantsTable())) { + getInstance(start).addState(CREATING_NEW_TABLE, null); + update(con, AccountInfoQueries.getQueryToCreateRecipeUserTenantsTable(start), NO_OP_SETTER); + + // indexes + update(con, AccountInfoQueries.getQueryToCreateTenantIndexForRecipeUserTenantsTable(start), NO_OP_SETTER); + update(con, AccountInfoQueries.getQueryToCreateRecipeUserIdIndexForRecipeUserTenantsTable(start), NO_OP_SETTER); + update(con, AccountInfoQueries.getQueryToCreateAccountInfoIndexForRecipeUserTenantsTable(start), NO_OP_SETTER); + } + + if (!doesTableExists(start, con, Config.getConfig(start).getPrimaryUserTenantsTable())) { + getInstance(start).addState(CREATING_NEW_TABLE, null); + update(con, AccountInfoQueries.getQueryToCreatePrimaryUserTenantsTable(start), NO_OP_SETTER); + + // indexes + update(con, AccountInfoQueries.getQueryToCreatePrimaryUserIndexForPrimaryUserTenantsTable(start), NO_OP_SETTER); + } + } catch (Exception e) { if (e.getMessage().contains("schema") && e.getMessage().contains("does not exist") && numberOfRetries < 1) { @@ -779,7 +805,10 @@ public static void deleteAllTables(Start start) throws SQLException, StorageQuer + getConfig(start).getKeyValueTable() + "," + getConfig(start).getAppIdToUserIdTable() + "," + getConfig(start).getUserIdMappingTable() + "," + + getConfig(start).getRecipeUserTenantsTable() + "," + + getConfig(start).getRecipeUserAccountInfosTable() + "," + getConfig(start).getUsersTable() + "," + + getConfig(start).getPrimaryUserTenantsTable() + "," + getConfig(start).getAccessTokenSigningKeysTable() + "," + getConfig(start).getTenantFirstFactorsTable() + "," + getConfig(start).getTenantRequiredSecondaryFactorsTable() + "," @@ -875,8 +904,12 @@ public static KeyValueInfo getKeyValue(Start start, TenantIdentifier tenantIdent public static KeyValueInfo getKeyValue_Transaction(Start start, Connection con, TenantIdentifier tenantIdentifier, String key) throws SQLException, StorageQueryException { + + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock( + con, tenantIdentifier.getAppId() + "~" + tenantIdentifier.getTenantId() + "~" + key); + String QUERY = "SELECT value, created_at_time FROM " + getConfig(start).getKeyValueTable() - + " WHERE app_id = ? AND tenant_id = ? AND name = ? FOR UPDATE"; + + " WHERE app_id = ? AND tenant_id = ? AND name = ?"; return execute(con, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); @@ -1398,13 +1431,16 @@ public static void linkAccounts_Transaction(Start start, Connection sqlCon, AppI throws SQLException, StorageQueryException { { String QUERY = "UPDATE " + getConfig(start).getUsersTable() + - " SET is_linked_or_is_a_primary_user = true, primary_or_recipe_user_id = ? WHERE app_id = ? AND " + - "user_id = ?"; + " SET is_linked_or_is_a_primary_user = true, primary_or_recipe_user_id = (" + + " SELECT primary_user_id FROM " + getConfig(start).getRecipeUserAccountInfosTable() + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1" + + ") WHERE app_id = ? AND user_id = ?"; update(sqlCon, QUERY, pst -> { - pst.setString(1, primaryUserId); - pst.setString(2, appIdentifier.getAppId()); - pst.setString(3, recipeUserId); + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, recipeUserId); }); } @@ -1412,13 +1448,16 @@ public static void linkAccounts_Transaction(Start start, Connection sqlCon, AppI { String QUERY = "UPDATE " + getConfig(start).getAppIdToUserIdTable() + - " SET is_linked_or_is_a_primary_user = true, primary_or_recipe_user_id = ? WHERE app_id = ? AND " + - "user_id = ?"; + " SET is_linked_or_is_a_primary_user = true, primary_or_recipe_user_id = (" + + " SELECT primary_user_id FROM " + getConfig(start).getRecipeUserAccountInfosTable() + + " WHERE app_id = ? AND recipe_user_id = ? LIMIT 1" + + ") WHERE app_id = ? AND user_id = ?"; update(sqlCon, QUERY, pst -> { - pst.setString(1, primaryUserId); - pst.setString(2, appIdentifier.getAppId()); - pst.setString(3, recipeUserId); + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, recipeUserId); }); } } @@ -1515,29 +1554,6 @@ public static void unlinkAccounts_Transaction(Start start, Connection sqlCon, Ap } } - public static AuthRecipeUserInfo[] listPrimaryUsersByPhoneNumber_Transaction(Start start, Connection sqlCon, - AppIdentifier appIdentifier, - String phoneNumber) - throws SQLException, StorageQueryException { - // we first lock on the table based on phoneNumber and tenant - this will ensure that any other - // query happening related to the account linking on this phone number / tenant will wait for this to finish, - // and vice versa. - - PasswordlessQueries.lockPhoneAndTenant_Transaction(start, sqlCon, appIdentifier, phoneNumber); - - // now that we have locks on all the relevant tables, we can read from them safely - List userIds = PasswordlessQueries.listUserIdsByPhoneNumber_Transaction(start, sqlCon, appIdentifier, - phoneNumber); - - List result = getPrimaryUserInfoForUserIds_Transaction(start, sqlCon, appIdentifier, - userIds); - - // this is going to order them based on oldest that joined to newest that joined. - result.sort(Comparator.comparingLong(o -> o.timeJoined)); - - return result.toArray(new AuthRecipeUserInfo[0]); - } - public static AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo(Start start, AppIdentifier appIdentifier, String thirdPartyId, @@ -1578,78 +1594,6 @@ public static AuthRecipeUserInfo[] listPrimaryUsersByThirdPartyInfo_Transaction( return result.toArray(new AuthRecipeUserInfo[0]); } - public static AuthRecipeUserInfo[] listPrimaryUsersByEmail_Transaction(Start start, Connection sqlCon, - AppIdentifier appIdentifier, - String email) - throws SQLException, StorageQueryException { - // we first lock on the three tables based on email and tenant - this will ensure that any other - // query happening related to the account linking on this email / tenant will wait for this to finish, - // and vice versa. - - EmailPasswordQueries.lockEmail_Transaction(start, sqlCon, appIdentifier, email); - - ThirdPartyQueries.lockEmail_Transaction(start, sqlCon, appIdentifier, email); - - PasswordlessQueries.lockEmail_Transaction(start, sqlCon, appIdentifier, email); - - // now that we have locks on all the relevant tables, we can read from them safely - List userIds = new ArrayList<>(); - userIds.addAll(EmailPasswordQueries.getPrimaryUserIdsUsingEmail_Transaction(start, sqlCon, appIdentifier, - email)); - - userIds.addAll(PasswordlessQueries.getPrimaryUserIdsUsingEmail_Transaction(start, sqlCon, appIdentifier, - email)); - - userIds.addAll(ThirdPartyQueries.getPrimaryUserIdUsingEmail_Transaction(start, sqlCon, appIdentifier, email)); - - String webauthnUserId = WebAuthNQueries.getPrimaryUserIdForAppUsingEmail_Transaction(start, sqlCon, - appIdentifier, email); - if(webauthnUserId != null) { - userIds.add(webauthnUserId); - } - - // remove duplicates from userIds - Set userIdsSet = new HashSet<>(userIds); - userIds = new ArrayList<>(userIdsSet); - - List result = getPrimaryUserInfoForUserIds_Transaction(start, sqlCon, appIdentifier, - userIds); - - // this is going to order them based on oldest that joined to newest that joined. - result.sort(Comparator.comparingLong(o -> o.timeJoined)); - - return result.toArray(new AuthRecipeUserInfo[0]); - } - - public static AuthRecipeUserInfo[] listPrimaryUsersByMultipleEmailsOrPhonesOrThirdParty_Transaction(Start start, Connection sqlCon, - AppIdentifier appIdentifier, - List emails, List phones, - Map thirdpartyUserIdToThirdpartyId) - throws SQLException, StorageQueryException { - Set userIds = new HashSet<>(); - - //collect ids by email - userIds.addAll(EmailPasswordQueries.getPrimaryUserIdsUsingMultipleEmails_Transaction(start, sqlCon, appIdentifier, - emails)); - userIds.addAll(PasswordlessQueries.getPrimaryUserIdsUsingMultipleEmails_Transaction(start, sqlCon, appIdentifier, - emails)); - userIds.addAll(ThirdPartyQueries.getPrimaryUserIdsUsingMultipleEmails_Transaction(start, sqlCon, appIdentifier, emails)); - - //collect ids by phone - userIds.addAll(PasswordlessQueries.listUserIdsByMultiplePhoneNumber_Transaction(start, sqlCon, appIdentifier, phones)); - - //collect ids by thirdparty - userIds.addAll(ThirdPartyQueries.listUserIdsByMultipleThirdPartyInfo_Transaction(start, sqlCon, appIdentifier, thirdpartyUserIdToThirdpartyId)); - - //collect ids by webauthn - userIds.addAll(WebAuthNQueries.getPrimaryUserIdsUsingEmails_Transaction(start, sqlCon, appIdentifier, emails)); - - List result = getPrimaryUserInfoForUserIds_Transaction(start, sqlCon, appIdentifier, - new ArrayList<>(userIds)); - - return result.toArray(new AuthRecipeUserInfo[0]); - } - public static AuthRecipeUserInfo[] listPrimaryUsersByEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { @@ -1781,17 +1725,6 @@ public static AuthRecipeUserInfo getPrimaryUserInfoForUserId_Transaction(Start s return result.get(0); } - public static List getPrimaryUserInfosForUserIds_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, List ids) - throws SQLException, StorageQueryException { - - List result = getPrimaryUserInfoForUserIds_Transaction(start, con, appIdentifier, ids); - if (result.isEmpty()) { - return null; - } - return result; - } - private static List getPrimaryUserInfoForUserIds(Start start, AppIdentifier appIdentifier, List userIds) @@ -1929,7 +1862,6 @@ private static List getPrimaryUserInfoForUserIds_Transaction // for app_id pst.setString(index, appIdentifier.getAppId()); pst.setString(index+1, appIdentifier.getAppId()); -// System.out.println(pst); }, result -> { List parsedResult = new ArrayList<>(); while (result.next()) { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java index b0c7ffe1..f18eadd0 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java @@ -243,6 +243,10 @@ public static void overwriteTenantConfig(Start start, TenantConfig tenantConfig) Connection sqlCon = (Connection) con.getConnection(); { try { + { + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock( + sqlCon, tenantConfig.tenantIdentifier.getConnectionUriDomain() + "~" + tenantConfig.tenantIdentifier.getAppId() + "~" + tenantConfig.tenantIdentifier.getTenantId()); + } { String QUERY = "DELETE FROM " + getConfig(start).getTenantConfigsTable() + " WHERE connection_uri_domain = ? AND app_id = ? AND tenant_id = ?;"; diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java index ef7ce0e7..c3fd3fd4 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java @@ -16,10 +16,27 @@ package io.supertokens.storage.postgresql.queries; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import static io.supertokens.pluginInterface.RECIPE_ID.PASSWORDLESS; import io.supertokens.pluginInterface.RowMapper; +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; -import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; @@ -30,21 +47,13 @@ import io.supertokens.pluginInterface.sqlStorage.SQLStorage.TransactionIsolationLevel; import io.supertokens.storage.postgresql.ConnectionPool; import io.supertokens.storage.postgresql.PreparedStatementValueSetter; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.executeBatch; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; -import io.supertokens.storage.postgresql.utils.Utils; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.stream.Collectors; - -import static io.supertokens.pluginInterface.RECIPE_ID.PASSWORDLESS; -import static io.supertokens.storage.postgresql.QueryExecutorTemplate.*; import static io.supertokens.storage.postgresql.config.Config.getConfig; +import io.supertokens.storage.postgresql.utils.Utils; public class PasswordlessQueries { public static String getQueryToCreateUsersTable(Start start) { @@ -444,6 +453,24 @@ public static AuthRecipeUserInfo createUser(Start start, TenantIdentifier tenant }); } + { // recipe_user_tenants + ACCOUNT_INFO_TYPE accountInfoType; + String accountInfoValue; + + if (email != null) { + accountInfoType = ACCOUNT_INFO_TYPE.EMAIL; + accountInfoValue = email; + } else if (phoneNumber != null) { + accountInfoType = ACCOUNT_INFO_TYPE.PHONE_NUMBER; + accountInfoValue = phoneNumber; + } else { + throw new IllegalArgumentException("Either email or phoneNumber must be provided"); + } + + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, id, + PASSWORDLESS.toString(), accountInfoType, "", "", accountInfoValue); + } + { // passwordless_users String QUERY = "INSERT INTO " + getConfig(start).getPasswordlessUsersTable() + "(app_id, user_id, email, phone_number, time_joined)" + " VALUES(?, ?, ?, ?, ?)"; @@ -468,6 +495,7 @@ public static AuthRecipeUserInfo createUser(Start start, TenantIdentifier tenant pst.setString(5, phoneNumber); }); } + UserInfoPartial userInfo = new UserInfoPartial(id, email, phoneNumber, timeJoined); fillUserInfoWithTenantIds_transaction(start, sqlCon, tenantIdentifier.toAppIdentifier(), userInfo); fillUserInfoWithVerified_transaction(start, sqlCon, tenantIdentifier.toAppIdentifier(), userInfo); @@ -814,92 +842,6 @@ private static UserInfoPartial getUserById_Transaction(Start start, Connection s }); } - public static List lockEmail_Transaction(Start start, Connection con, AppIdentifier appIdentifier, - String email) throws StorageQueryException, SQLException { - // we don't need a FOR UPDATE here because this is already part of a transaction, and locked on - // app_id_to_user_id table - String QUERY = "SELECT user_id FROM " + getConfig(start).getPasswordlessUsersTable() + - " WHERE app_id = ? AND email = ? FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, email); - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - - public static List lockEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List emails) - throws StorageQueryException, SQLException { - if(emails == null || emails.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT user_id FROM " + getConfig(start).getPasswordlessUsersTable() + - " WHERE app_id = ? AND email IN (" + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + ") FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < emails.size(); i++) { - pst.setString(2 + i, emails.get(i)); - } - }, result -> { - List results = new ArrayList<>(); - while (result.next()) { - results.add(result.getString("user_id")); - } - return results; - }); - } - - public static List lockPhoneAndTenant_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - String phoneNumber) - throws SQLException, StorageQueryException { - - String QUERY = "SELECT user_id FROM " + getConfig(start).getPasswordlessUsersTable() + - " WHERE app_id = ? AND phone_number = ? FOR UPDATE"; - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, phoneNumber); - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - - public static List lockPhoneAndTenant_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List phones) - throws StorageQueryException, SQLException { - if(phones == null || phones.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT user_id FROM " + getConfig(start).getPasswordlessUsersTable() + - " WHERE app_id = ? AND phone_number IN (" + Utils.generateCommaSeperatedQuestionMarks(phones.size()) + ") FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < phones.size(); i++) { - pst.setString(2 + i, phones.get(i)); - } - }, result -> { - List results = new ArrayList<>(); - while (result.next()) { - results.add(result.getString("user_id")); - } - return results; - }); - } - public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier tenantIdentifier, String email) throws StorageQueryException, SQLException { @@ -921,55 +863,6 @@ public static String getPrimaryUserIdUsingEmail(Start start, TenantIdentifier te }); } - public static List getPrimaryUserIdsUsingEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - String email) - throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getPasswordlessUsersTable() + " AS pless" + - " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS all_users" + - " ON pless.app_id = all_users.app_id AND pless.user_id = all_users.user_id" + - " WHERE pless.app_id = ? AND pless.email = ?"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, email); - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - - public static List getPrimaryUserIdsUsingMultipleEmails_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List emails) - throws StorageQueryException, SQLException { - if(emails == null || emails.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getPasswordlessUsersTable() + " AS ep" + - " JOIN " + getConfig(start).getAppIdToUserIdTable() + " AS all_users" + - " ON ep.app_id = all_users.app_id AND ep.user_id = all_users.user_id" + - " WHERE ep.app_id = ? AND ep.email IN ( " + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + " )"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < emails.size(); i++) { - pst.setString(2+i, emails.get(i)); - } - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - public static String getPrimaryUserByPhoneNumber(Start start, TenantIdentifier tenantIdentifier, @Nonnull String phoneNumber) throws StorageQueryException, SQLException { @@ -991,55 +884,6 @@ public static String getPrimaryUserByPhoneNumber(Start start, TenantIdentifier t }); } - public static List listUserIdsByPhoneNumber_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - @Nonnull String phoneNumber) - throws StorageQueryException, SQLException { - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getPasswordlessUsersTable() + " AS pless" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON pless.app_id = all_users.app_id AND pless.user_id = all_users.user_id" + - " WHERE pless.app_id = ? AND pless.phone_number = ?"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, phoneNumber); - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - - public static List listUserIdsByMultiplePhoneNumber_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - @Nonnull List phoneNumbers) - throws StorageQueryException, SQLException { - if(phoneNumbers == null || phoneNumbers.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT DISTINCT all_users.primary_or_recipe_user_id AS user_id " - + "FROM " + getConfig(start).getPasswordlessUsersTable() + " AS pless" + - " JOIN " + getConfig(start).getUsersTable() + " AS all_users" + - " ON pless.app_id = all_users.app_id AND pless.user_id = all_users.user_id" + - " WHERE pless.app_id = ? AND pless.phone_number IN ( "+ Utils.generateCommaSeperatedQuestionMarks(phoneNumbers.size()) +" )"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < phoneNumbers.size(); i++) { - pst.setString(2 + i, phoneNumbers.get(i)); - } - }, result -> { - List userIds = new ArrayList<>(); - while (result.next()) { - userIds.add(result.getString("user_id")); - } - return userIds; - }); - } - public static boolean addUserIdToTenant_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) throws StorageQueryException, SQLException, UnknownUserIdException { @@ -1238,12 +1082,12 @@ public static void importUsers_Transaction(Connection sqlCon, Start start, throws SQLException, StorageQueryException { String app_id_to_user_id_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)"; String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() + - "(app_id, tenant_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, " + + "(app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, " + "primary_or_recipe_user_time_joined)" + - " VALUES(?, ?, ?, ?, ?, ?, ?)"; + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; String passwordless_users_QUERY = "INSERT INTO " + getConfig(start).getPasswordlessUsersTable() + "(app_id, user_id, email, phone_number, time_joined)" + " VALUES(?, ?, ?, ?, ?)"; @@ -1251,48 +1095,77 @@ public static void importUsers_Transaction(Connection sqlCon, Start start, String passwordless_user_to_tenant_QUERY = "INSERT INTO " + getConfig(start).getPasswordlessUserToTenantTable() + "(app_id, tenant_id, user_id, email, phone_number)" + " VALUES(?, ?, ?, ?, ?)"; + List recipeUserAccountInfoBatch = new ArrayList<>(); + List recipeUserTenantsBatch = new ArrayList<>(); + List appIdToUserIdBatch = new ArrayList<>(); List allAuthRecipeUsersBatch = new ArrayList<>(); List passwordlessUsersBatch = new ArrayList<>(); List passwordlessUserToTenantBatch = new ArrayList<>(); - for (PasswordlessImportUser user: users){ - TenantIdentifier tenantIdentifier = user.tenantIdentifier; + for (PasswordlessImportUser user: users) { + String appId = user.appIdentifier.getAppId(); + String primaryOrRecipeUserId = user.primaryUserId != null ? user.primaryUserId : user.userId; + boolean isLinkedOrIsPrimaryUser = user.primaryUserId != null; + + // Recipe Account Info + if (user.email != null) { + AccountInfoQueries.addRecipeUserAccountInfoToBatch(recipeUserAccountInfoBatch, user.appIdentifier, user.userId, PASSWORDLESS.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", user.email, isLinkedOrIsPrimaryUser ? primaryOrRecipeUserId : null); + } + if (user.phoneNumber != null) { + AccountInfoQueries.addRecipeUserAccountInfoToBatch(recipeUserAccountInfoBatch, user.appIdentifier, user.userId, PASSWORDLESS.toString(), ACCOUNT_INFO_TYPE.PHONE_NUMBER, "", "", user.phoneNumber, isLinkedOrIsPrimaryUser ? primaryOrRecipeUserId : null); + } + + // Recipe User Tenants + if (user.email != null) { + AccountInfoQueries.addRecipeUserTenantsToBatch(recipeUserTenantsBatch, user.appIdentifier, user.userId, PASSWORDLESS.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", user.email, user.recipeUserTenantIds); + } + if (user.phoneNumber != null) { + AccountInfoQueries.addRecipeUserTenantsToBatch(recipeUserTenantsBatch, user.appIdentifier, user.userId, PASSWORDLESS.toString(), ACCOUNT_INFO_TYPE.PHONE_NUMBER, "", "", user.phoneNumber, user.recipeUserTenantIds); + } + appIdToUserIdBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(1, appId); pst.setString(2, user.userId); - pst.setString(3, user.userId); - pst.setString(4, PASSWORDLESS.toString()); - }); - - allAuthRecipeUsersBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); - pst.setString(2, tenantIdentifier.getTenantId()); - pst.setString(3, user.userId); - pst.setString(4, user.userId); + pst.setString(3, primaryOrRecipeUserId); + pst.setBoolean(4, isLinkedOrIsPrimaryUser); pst.setString(5, PASSWORDLESS.toString()); - pst.setLong(6, user.timeJoinedMSSinceEpoch); - pst.setLong(7, user.timeJoinedMSSinceEpoch); }); passwordlessUsersBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(1, appId); pst.setString(2, user.userId); pst.setString(3, user.email); pst.setString(4, user.phoneNumber); pst.setLong(5, user.timeJoinedMSSinceEpoch); }); - passwordlessUserToTenantBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); - pst.setString(2, tenantIdentifier.getTenantId()); - pst.setString(3, user.userId); - pst.setString(4, user.email); - pst.setString(5, user.phoneNumber); - }); + // Generate entries for all recipe user tenant IDs + for (String tenantId : user.recipeUserTenantIds) { + allAuthRecipeUsersBatch.add(pst -> { + pst.setString(1, appId); + pst.setString(2, tenantId); + pst.setString(3, user.userId); + pst.setString(4, primaryOrRecipeUserId); + pst.setBoolean(5, isLinkedOrIsPrimaryUser); + pst.setString(6, PASSWORDLESS.toString()); + pst.setLong(7, user.timeJoinedMSSinceEpoch); + pst.setLong(8, user.timeJoinedMSSinceEpoch); + }); + passwordlessUserToTenantBatch.add(pst -> { + pst.setString(1, appId); + pst.setString(2, tenantId); + pst.setString(3, user.userId); + pst.setString(4, user.email); + pst.setString(5, user.phoneNumber); + }); + } } + executeBatch(sqlCon, AccountInfoQueries.getRecipeUserAccountInfoBatchQuery(start), recipeUserAccountInfoBatch); + executeBatch(sqlCon, AccountInfoQueries.getRecipeUserTenantBatchQuery(start), recipeUserTenantsBatch); + executeBatch(sqlCon, app_id_to_user_id_QUERY, appIdToUserIdBatch); executeBatch(sqlCon, all_auth_recipe_users_QUERY, allAuthRecipeUsersBatch); executeBatch(sqlCon, passwordless_users_QUERY, passwordlessUsersBatch); diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java index 6e4c7906..fd1201bf 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java @@ -16,10 +16,24 @@ package io.supertokens.storage.postgresql.queries; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static io.supertokens.pluginInterface.RECIPE_ID.THIRD_PARTY; import io.supertokens.pluginInterface.RowMapper; +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; -import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; @@ -27,19 +41,13 @@ import io.supertokens.pluginInterface.thirdparty.ThirdPartyImportUser; import io.supertokens.storage.postgresql.ConnectionPool; import io.supertokens.storage.postgresql.PreparedStatementValueSetter; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.executeBatch; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; -import io.supertokens.storage.postgresql.utils.Utils; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.stream.Collectors; - -import static io.supertokens.pluginInterface.RECIPE_ID.THIRD_PARTY; -import static io.supertokens.storage.postgresql.QueryExecutorTemplate.*; import static io.supertokens.storage.postgresql.config.Config.getConfig; +import io.supertokens.storage.postgresql.utils.Utils; public class ThirdPartyQueries { @@ -136,6 +144,17 @@ public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIden }); } + { // recipe_user_tenants + // Insert row for email + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, id, + THIRD_PARTY.toString(), ACCOUNT_INFO_TYPE.EMAIL, thirdParty.id, thirdParty.userId, email); + + // Insert row for third party id + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, id, + THIRD_PARTY.toString(), ACCOUNT_INFO_TYPE.THIRD_PARTY, "", "", + new LoginMethod.ThirdParty(thirdParty.id, thirdParty.userId).getAccountInfoValue()); + } + { // thirdparty_users String QUERY = "INSERT INTO " + getConfig(start).getThirdPartyUsersTable() + "(app_id, third_party_id, third_party_user_id, user_id, email, time_joined)" @@ -207,49 +226,6 @@ public static void deleteUser_Transaction(Connection sqlCon, Start start, AppIde } } - public static List lockEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - String email) throws SQLException, StorageQueryException { - String QUERY = "SELECT tp.user_id as user_id " - + "FROM " + getConfig(start).getThirdPartyUsersTable() + " AS tp" + - " WHERE tp.app_id = ? AND tp.email = ? FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - pst.setString(2, email); - }, result -> { - List finalResult = new ArrayList<>(); - while (result.next()) { - finalResult.add(result.getString("user_id")); - } - return finalResult; - }); - } - - public static List lockEmail_Transaction(Start start, Connection con, - AppIdentifier appIdentifier, - List emails) - throws StorageQueryException, SQLException { - if(emails == null || emails.isEmpty()){ - return new ArrayList<>(); - } - String QUERY = "SELECT user_id FROM " + getConfig(start).getThirdPartyUsersTable() + - " WHERE app_id = ? AND email IN (" + Utils.generateCommaSeperatedQuestionMarks(emails.size()) + ") FOR UPDATE"; - - return execute(con, QUERY, pst -> { - pst.setString(1, appIdentifier.getAppId()); - for (int i = 0; i < emails.size(); i++) { - pst.setString(2 + i, emails.get(i)); - } - }, result -> { - List results = new ArrayList<>(); - while (result.next()) { - results.add(result.getString("user_id")); - } - return results; - }); - } - public static List lockThirdPartyInfoAndTenant_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String thirdPartyId, String thirdPartyUserId) @@ -653,13 +629,13 @@ public static void importUser_Transaction(Start start, Connection sqlConnection, throws SQLException, StorageQueryException { String app_id_userid_QUERY = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() - + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + + "(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)" + " VALUES(?, ?, ?, ?, ?)"; String all_auth_recipe_users_QUERY = "INSERT INTO " + getConfig(start).getUsersTable() + - "(app_id, tenant_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, " + + "(app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, " + "primary_or_recipe_user_time_joined)" + - " VALUES(?, ?, ?, ?, ?, ?, ?)"; + " VALUES(?, ?, ?, ?, ?, ?, ?, ?)"; String thirdparty_users_QUERY = "INSERT INTO " + getConfig(start).getThirdPartyUsersTable() + "(app_id, third_party_id, third_party_user_id, user_id, email, time_joined)" @@ -669,32 +645,37 @@ public static void importUser_Transaction(Start start, Connection sqlConnection, + "(app_id, tenant_id, user_id, third_party_id, third_party_user_id)" + " VALUES(?, ?, ?, ?, ?)"; + List recipeUserAccountInfoBatch = new ArrayList<>(); + List recipeUserTenantsBatch = new ArrayList<>(); + List appIdToUserIdBatch = new ArrayList<>(); List allAuthRecipeUsersBatch = new ArrayList<>(); List thirdPartyUsersBatch = new ArrayList<>(); List thirdPartyUsersToTenantBatch = new ArrayList<>(); for (ThirdPartyImportUser user : users) { - TenantIdentifier tenantIdentifier = user.tenantIdentifier; + String appId = user.appIdentifier.getAppId(); + String primaryOrRecipeUserId = user.primaryUserId != null ? user.primaryUserId : user.userId; + boolean isLinkedOrIsPrimaryUser = user.primaryUserId != null; + + // Recipe Account Info + AccountInfoQueries.addRecipeUserAccountInfoToBatch(recipeUserAccountInfoBatch, user.appIdentifier, user.userId, THIRD_PARTY.toString(), ACCOUNT_INFO_TYPE.THIRD_PARTY, "", "", new LoginMethod.ThirdParty(user.thirdpartyId, user.thirdpartyUserId).getAccountInfoValue(), isLinkedOrIsPrimaryUser ? primaryOrRecipeUserId : null); + AccountInfoQueries.addRecipeUserAccountInfoToBatch(recipeUserAccountInfoBatch, user.appIdentifier, user.userId, THIRD_PARTY.toString(), ACCOUNT_INFO_TYPE.EMAIL, user.thirdpartyId, user.thirdpartyUserId, user.email, isLinkedOrIsPrimaryUser ? primaryOrRecipeUserId : null); + + // Recipe User Tenants + AccountInfoQueries.addRecipeUserTenantsToBatch(recipeUserTenantsBatch, user.appIdentifier, user.userId, THIRD_PARTY.toString(), ACCOUNT_INFO_TYPE.THIRD_PARTY, "", "", new LoginMethod.ThirdParty(user.thirdpartyId, user.thirdpartyUserId).getAccountInfoValue(), user.recipeUserTenantIds); + AccountInfoQueries.addRecipeUserTenantsToBatch(recipeUserTenantsBatch, user.appIdentifier, user.userId, THIRD_PARTY.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", user.email, user.recipeUserTenantIds); + appIdToUserIdBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(1, appId); pst.setString(2, user.userId); - pst.setString(3, user.userId); - pst.setString(4, THIRD_PARTY.toString()); - }); - - allAuthRecipeUsersBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); - pst.setString(2, tenantIdentifier.getTenantId()); - pst.setString(3, user.userId); - pst.setString(4, user.userId); + pst.setString(3, primaryOrRecipeUserId); + pst.setBoolean(4, isLinkedOrIsPrimaryUser); pst.setString(5, THIRD_PARTY.toString()); - pst.setLong(6, user.timeJoinedMSSinceEpoch); - pst.setLong(7, user.timeJoinedMSSinceEpoch); }); thirdPartyUsersBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(1, appId); pst.setString(2, user.thirdpartyId); pst.setString(3, user.thirdpartyUserId); pst.setString(4, user.userId); @@ -702,15 +683,32 @@ public static void importUser_Transaction(Start start, Connection sqlConnection, pst.setLong(6, user.timeJoinedMSSinceEpoch); }); - thirdPartyUsersToTenantBatch.add(pst -> { - pst.setString(1, tenantIdentifier.getAppId()); - pst.setString(2, tenantIdentifier.getTenantId()); - pst.setString(3, user.userId); - pst.setString(4, user.thirdpartyId); - pst.setString(5, user.thirdpartyUserId); - }); + // Generate entries for all recipe user tenant IDs + for (String tenantId : user.recipeUserTenantIds) { + allAuthRecipeUsersBatch.add(pst -> { + pst.setString(1, appId); + pst.setString(2, tenantId); + pst.setString(3, user.userId); + pst.setString(4, primaryOrRecipeUserId); + pst.setBoolean(5, isLinkedOrIsPrimaryUser); + pst.setString(6, THIRD_PARTY.toString()); + pst.setLong(7, user.timeJoinedMSSinceEpoch); + pst.setLong(8, user.timeJoinedMSSinceEpoch); + }); + + thirdPartyUsersToTenantBatch.add(pst -> { + pst.setString(1, appId); + pst.setString(2, tenantId); + pst.setString(3, user.userId); + pst.setString(4, user.thirdpartyId); + pst.setString(5, user.thirdpartyUserId); + }); + } } + executeBatch(sqlConnection, AccountInfoQueries.getRecipeUserAccountInfoBatchQuery(start), recipeUserAccountInfoBatch); + executeBatch(sqlConnection, AccountInfoQueries.getRecipeUserTenantBatchQuery(start), recipeUserTenantsBatch); + executeBatch(sqlConnection, app_id_userid_QUERY, appIdToUserIdBatch); executeBatch(sqlConnection, all_auth_recipe_users_QUERY, allAuthRecipeUsersBatch); executeBatch(sqlConnection, thirdparty_users_QUERY, thirdPartyUsersBatch); diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java index 05785506..756ec2f7 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java @@ -16,25 +16,27 @@ package io.supertokens.storage.postgresql.queries; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import com.google.gson.JsonObject; import com.google.gson.JsonParser; + import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.storage.postgresql.PreparedStatementValueSetter; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.executeBatch; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; -import io.supertokens.storage.postgresql.utils.Utils; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static io.supertokens.storage.postgresql.QueryExecutorTemplate.*; import static io.supertokens.storage.postgresql.config.Config.getConfig; +import io.supertokens.storage.postgresql.utils.Utils; public class UserMetadataQueries { @@ -121,6 +123,7 @@ public static void setMultipleUsersMetadatas_Transaction(Start start, Connection public static JsonObject getUserMetadata_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String userId) throws SQLException, StorageQueryException { + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock(con, appIdentifier.getAppId() + "~" + userId); String QUERY = "SELECT user_metadata FROM " + getConfig(start).getUserMetadataTable() + " WHERE app_id = ? AND user_id = ? FOR UPDATE"; return execute(con, QUERY, pst -> { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/Utils.java b/src/main/java/io/supertokens/storage/postgresql/queries/Utils.java new file mode 100644 index 00000000..6c343a79 --- /dev/null +++ b/src/main/java/io/supertokens/storage/postgresql/queries/Utils.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.postgresql.queries; + +import java.sql.Connection; +import java.sql.SQLException; + +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.storage.postgresql.LockFailure; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; + +public class Utils { + + /** + * Acquires a PostgreSQL advisory lock using two string keys. + * Uses pg_try_advisory_xact_lock which is transaction-scoped (automatically released on commit/rollback). + * + * @param con The database connection (must be within a transaction) + * @param key Key for the lock (e.g., appId) + * @throws SQLException If a database error occurs + * @throws StorageQueryException If a query error occurs + * @throws LockFailure If the lock could not be acquired + */ + public static void takeAdvisoryLock(Connection con, String key) + throws SQLException, StorageQueryException { + String LOCK_QUERY = "SELECT pg_try_advisory_xact_lock(hashtext(?))"; + boolean lockAcquired = execute(con, LOCK_QUERY, pst -> { + pst.setString(1, key); + }, result -> { + if (result.next()) { + return result.getBoolean(1); + } + return false; + }); + if (!lockAcquired) { + throw new StorageQueryException(new LockFailure()); + } + } +} diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java index 063a8e86..b4b766cf 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/WebAuthNQueries.java @@ -17,6 +17,21 @@ package io.supertokens.storage.postgresql.queries; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jetbrains.annotations.Nullable; + +import io.supertokens.pluginInterface.authRecipe.ACCOUNT_INFO_TYPE; +import static io.supertokens.pluginInterface.RECIPE_ID.WEBAUTHN; import io.supertokens.pluginInterface.RowMapper; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; @@ -27,19 +42,11 @@ import io.supertokens.pluginInterface.webauthn.AccountRecoveryTokenInfo; import io.supertokens.pluginInterface.webauthn.WebAuthNOptions; import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential; -import io.supertokens.storage.postgresql.Start; -import io.supertokens.storage.postgresql.utils.Utils; -import org.jetbrains.annotations.Nullable; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; - -import static io.supertokens.pluginInterface.RECIPE_ID.WEBAUTHN; import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; +import io.supertokens.storage.postgresql.Start; import static io.supertokens.storage.postgresql.config.Config.getConfig; +import io.supertokens.storage.postgresql.utils.Utils; public class WebAuthNQueries { @@ -318,6 +325,10 @@ public static void createUser_Transaction(Start start, Connection sqlCon, Tenant pst.setLong(7, timeJoined); }); + // recipe_user_tenants + AccountInfoQueries.addRecipeUserAccountInfo_Transaction(start, sqlCon, tenantIdentifier, userId, + WEBAUTHN.toString(), ACCOUNT_INFO_TYPE.EMAIL, "", "", email); + // webauthn_user_to_tenant String insertWebauthNUsersToTenant = "INSERT INTO " + getConfig(start).getWebAuthNUserToTenantTable() diff --git a/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java index 17e158c8..b6566021 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DbConnectionPoolTest.java @@ -35,7 +35,7 @@ import com.google.gson.JsonObject; import io.supertokens.ProcessState; -import io.supertokens.emailpassword.exceptions.EmailChangeNotAllowedException; +import io.supertokens.pluginInterface.authRecipe.exceptions.EmailChangeNotAllowedException; import io.supertokens.featureflag.EE_FEATURES; import io.supertokens.featureflag.FeatureFlagTestContent; import io.supertokens.multitenancy.Multitenancy; diff --git a/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java b/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java index 58d5e088..10556dee 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java @@ -74,7 +74,7 @@ public void beforeEach() { } @Rule - public Retry retry = new Retry(3); + public TestRule retryFlaky = Utils.retryFlakyTest(); @Test public void transactionDeadlockTesting() @@ -267,7 +267,8 @@ public void testCodeCreationRapidlyWithDifferentEmails() throws Exception { .checkOrWaitForEventInPlugin( io.supertokens.storage.postgresql.ProcessState.PROCESS_STATE.DEADLOCK_NOT_RESOLVED)); - assertNotNull(process + // Deadlock should not happen + assertNull(process .checkOrWaitForEventInPlugin( io.supertokens.storage.postgresql.ProcessState.PROCESS_STATE.DEADLOCK_FOUND)); @@ -433,7 +434,7 @@ public void testConcurrentDeleteAndUpdate() throws Exception { assertTrue(!t1Failed.get() && !t2Failed.get()); assert (t1State.get().equals("commit") && t2State.get().equals("commit")); - assertNotNull(process.checkOrWaitForEventInPlugin( + assertNull(process.checkOrWaitForEventInPlugin( io.supertokens.storage.postgresql.ProcessState.PROCESS_STATE.DEADLOCK_FOUND)); process.kill(); @@ -519,7 +520,7 @@ public void testConcurrentDeleteAndInsert() throws Exception { t1Failed.set(false); return null; - }, SQLStorage.TransactionIsolationLevel.SERIALIZABLE); + }); } catch (StorageQueryException | StorageTransactionLogicException e) { // This is expected because of "could not serialize access" t1Failed.set(true); @@ -601,7 +602,7 @@ public void testConcurrentDeleteAndInsert() throws Exception { assertTrue(!t1Failed.get() && t2Failed.get()); assert (t1State.get().equals("commit") && t2State.get().equals("query")); - assertNotNull(process + assertNull(process .checkOrWaitForEventInPlugin( io.supertokens.storage.postgresql.ProcessState.PROCESS_STATE.DEADLOCK_FOUND, 1000)); @@ -652,6 +653,7 @@ public void testLinkAccountsInParallel() throws Exception { .checkOrWaitForEventInPlugin( io.supertokens.storage.postgresql.ProcessState.PROCESS_STATE.DEADLOCK_NOT_RESOLVED)); + // Deadlock should not occur assertNotNull(process .checkOrWaitForEventInPlugin( io.supertokens.storage.postgresql.ProcessState.PROCESS_STATE.DEADLOCK_FOUND)); diff --git a/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java b/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java index 7fd988ac..6761a6f6 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/ExceptionParsingTest.java @@ -23,7 +23,7 @@ import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateEmailException; import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicatePasswordResetTokenException; import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateUserIdException; -import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; +import io.supertokens.pluginInterface.authRecipe.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.emailpassword.sqlStorage.EmailPasswordSQLStorage; import io.supertokens.pluginInterface.emailverification.EmailVerificationTokenInfo; import io.supertokens.pluginInterface.emailverification.exception.DuplicateEmailVerificationTokenException; @@ -159,7 +159,7 @@ public void updateUsersEmail_TransactionExceptions() SignatureException, InvalidAlgorithmParameterException, NoSuchPaddingException, BadPaddingException, UnsupportedEncodingException, InvalidKeySpecException, IllegalBlockSizeException, StorageTransactionLogicException, DuplicateUserIdException, DuplicateEmailException, - TenantOrAppNotFoundException { + TenantOrAppNotFoundException, Exception { { String[] args = {"../"}; TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); @@ -184,6 +184,8 @@ public void updateUsersEmail_TransactionExceptions() throw new StorageTransactionLogicException(new Exception("This should throw")); } catch (DuplicateEmailException ex) { // expected + } catch (Exception e) { + throw new StorageTransactionLogicException(e); } return true; }); @@ -193,6 +195,8 @@ public void updateUsersEmail_TransactionExceptions() storage.updateUsersEmail_Transaction(new AppIdentifier(null, null), conn, userId, userEmail3); } catch (DuplicateEmailException ex) { throw new StorageQueryException(ex); + } catch (Exception e) { + throw new StorageTransactionLogicException(e); } return true; });