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 abf6cdce..3d4a101c 100644 --- a/src/main/java/io/supertokens/storage/postgresql/Start.java +++ b/src/main/java/io/supertokens/storage/postgresql/Start.java @@ -30,6 +30,11 @@ import javax.annotation.Nonnull; +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.storage.postgresql.queries.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -151,24 +156,6 @@ 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.ActiveUsersQueries; -import io.supertokens.storage.postgresql.queries.BulkImportQueries; -import io.supertokens.storage.postgresql.queries.DashboardQueries; -import io.supertokens.storage.postgresql.queries.EmailPasswordQueries; -import io.supertokens.storage.postgresql.queries.EmailVerificationQueries; -import io.supertokens.storage.postgresql.queries.GeneralQueries; -import io.supertokens.storage.postgresql.queries.JWTSigningQueries; -import io.supertokens.storage.postgresql.queries.MultitenancyQueries; -import io.supertokens.storage.postgresql.queries.OAuthQueries; -import io.supertokens.storage.postgresql.queries.PasswordlessQueries; -import io.supertokens.storage.postgresql.queries.SAMLQueries; -import io.supertokens.storage.postgresql.queries.SessionQueries; -import io.supertokens.storage.postgresql.queries.TOTPQueries; -import io.supertokens.storage.postgresql.queries.ThirdPartyQueries; -import io.supertokens.storage.postgresql.queries.UserIdMappingQueries; -import io.supertokens.storage.postgresql.queries.UserMetadataQueries; -import io.supertokens.storage.postgresql.queries.UserRolesQueries; -import io.supertokens.storage.postgresql.queries.WebAuthNQueries; @WithinOtelSpan public class Start @@ -342,7 +329,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 +371,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 +1100,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()) @@ -1565,6 +1555,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()) @@ -2159,6 +2152,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()) @@ -2913,6 +2916,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 +2988,8 @@ public boolean removeUserIdFromTenant(TenantIdentifier tenantIdentifier, String } else { throw new IllegalStateException("Should never come here!"); } + AccountInfoQueries.removeAccountInfoForPrimaryUserIfNecessary_Transaction(this, sqlCon, tenantIdentifier, userId); + AccountInfoQueries.removeAccountInfoForRecipeUser_Transaction(this, sqlCon, tenantIdentifier, userId); sqlCon.commit(); return removed; @@ -3529,6 +3536,7 @@ public void makePrimaryUser_Transaction(AppIdentifier appIdentifier, Transaction // 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); + AccountInfoQueries.addPrimaryUserAccountInfo_Transaction(this, sqlCon, appIdentifier, userId); } catch (SQLException e) { throw new StorageQueryException(e); } @@ -3553,6 +3561,7 @@ public void linkAccounts_Transaction(AppIdentifier appIdentifier, TransactionCon // 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); + AccountInfoQueries.reserveAccountInfoForLinking_Transaction(this, sqlCon, appIdentifier, recipeUserId, primaryUserId); } catch (SQLException e) { throw new StorageQueryException(e); } @@ -3580,6 +3589,7 @@ public void unlinkAccounts_Transaction(AppIdentifier appIdentifier, TransactionC // 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); + AccountInfoQueries.removeAccountInfoForPrimaryUserIfNecessary_Transaction(this, sqlCon, appIdentifier, recipeUserId); } catch (SQLException e) { throw new StorageQueryException(e); } @@ -3596,6 +3606,47 @@ public boolean doesUserIdExist_Transaction(TransactionConnection con, AppIdentif } } + @Override + public void checkIfLoginMethodCanBecomePrimary_Transaction(AppIdentifier appIdentifier, TransactionConnection con, + LoginMethod loginMethod) + throws AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, StorageQueryException { + try { + AccountInfoQueries.checkIfLoginMethodCanBecomePrimary_Transaction(this, con, appIdentifier, loginMethod); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + @Override + public void checkIfLoginMethodsCanBeLinked_Transaction(TransactionConnection con, AppIdentifier appIdentifier, + Set tenantIds, Set emails, + Set phoneNumbers, + Set thirdParties, + String primaryUserId) + throws AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, StorageQueryException { + try { + AccountInfoQueries.checkIfLoginMethodsCanBeLinked_Transaction(this, con, appIdentifier, tenantIds, emails, phoneNumbers, thirdParties, primaryUserId); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + @Override + 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.removeAccountInfoReservations_Transaction(this, con, appIdentifier, userId); + } + @Override public boolean checkIfUsesAccountLinking(AppIdentifier appIdentifier) throws StorageQueryException { try { @@ -4214,6 +4265,8 @@ public AuthRecipeUserInfo signUpWithCredentialsRegister_Transaction(TenantIdenti Logging.error(this, errorMessage.getMessage(), true); Logging.error(this, email, true); throw new DuplicateUserEmailException(); + } else if (isPrimaryKeyError(errorMessage, config.getRecipeUserTenantsTable())) { + throw new DuplicateUserEmailException(); } else if (isPrimaryKeyError(errorMessage, config.getWebAuthNUsersTable()) || isPrimaryKeyError(errorMessage, config.getUsersTable()) || isPrimaryKeyError(errorMessage, config.getWebAuthNUserToTenantTable()) @@ -4253,6 +4306,8 @@ public AuthRecipeUserInfo signUp_Transaction(TenantIdentifier tenantIdentifier, if (isUniqueConstraintError(errorMessage, config.getWebAuthNUserToTenantTable(),"email")) { throw new DuplicateUserEmailException(); + } else if (isPrimaryKeyError(errorMessage, config.getRecipeUserTenantsTable())) { + throw new DuplicateUserEmailException(); } else if (isPrimaryKeyError(errorMessage, config.getWebAuthNUsersTable()) || isPrimaryKeyError(errorMessage, config.getUsersTable()) || isPrimaryKeyError(errorMessage, config.getWebAuthNUserToTenantTable()) 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..5319e420 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,13 @@ public String getBulkImportUsersTable() { return addSchemaAndPrefixToTableName("bulk_import_users"); } + 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..adae4b58 --- /dev/null +++ b/src/main/java/io/supertokens/storage/postgresql/queries/AccountInfoQueries.java @@ -0,0 +1,989 @@ +/* + * 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 java.util.Set; + +import io.supertokens.pluginInterface.ACCOUNT_INFO_TYPE; +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.emailpassword.exceptions.DuplicateEmailException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +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 static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +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 { + private static String[] getPrimaryUserTenantsConflictForAddTenant(Connection sqlCon, String primaryUserTenantsTable, + TenantIdentifier tenantIdentifier, String supertokensUserId) throws SQLException, StorageQueryException { + return execute(sqlCon, + "SELECT e.primary_user_id, e.account_info_type FROM " + primaryUserTenantsTable + " e" + + " WHERE e.app_id = ? AND e.tenant_id = ? AND e.primary_user_id <> ?" + + " AND EXISTS (" + + " SELECT 1 FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.primary_user_id = ? AND p.tenant_id <> ?" + + " AND p.account_info_type = e.account_info_type" + + " AND p.account_info_value = e.account_info_value" + + " )" + + " AND NOT EXISTS (" + + " SELECT 1 FROM " + primaryUserTenantsTable + " already" + + " WHERE already.app_id = ? AND already.primary_user_id = ? AND already.tenant_id = ?" + + " )", + pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, supertokensUserId); + pst.setString(4, tenantIdentifier.getAppId()); + pst.setString(5, supertokensUserId); + pst.setString(6, tenantIdentifier.getTenantId()); + pst.setString(7, tenantIdentifier.getAppId()); + pst.setString(8, supertokensUserId); + pst.setString(9, tenantIdentifier.getTenantId()); + }, + rs -> { + String[] firstConflict = null; + while (rs.next()) { + String[] conflict = new String[]{rs.getString("primary_user_id"), rs.getString("account_info_type")}; + if (firstConflict == null) { + firstConflict = conflict; + } + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(conflict[1])) { + return conflict; + } + } + return firstConflict; + }); + } + + private static String getRecipeUserTenantsConflictTypeForAddTenant(Connection sqlCon, String recipeUserTenantsTable, + TenantIdentifier tenantIdentifier, String userId) throws SQLException, StorageQueryException { + return execute(sqlCon, + "SELECT e.account_info_type" + + " FROM " + recipeUserTenantsTable + " e" + + " WHERE e.app_id = ? AND e.tenant_id = ? AND e.recipe_user_id <> ?" + + " AND EXISTS (" + + " SELECT 1 FROM " + recipeUserTenantsTable + " r" + + " WHERE r.app_id = ? AND r.recipe_user_id = ? AND r.tenant_id <> ?" + + " AND r.recipe_id = e.recipe_id" + + " AND r.account_info_type = e.account_info_type" + + " AND r.account_info_value = e.account_info_value" + + " AND r.third_party_id IS NOT DISTINCT FROM e.third_party_id" + + " AND r.third_party_user_id IS NOT DISTINCT FROM e.third_party_user_id" + + " )" + + " AND NOT EXISTS (" + + " SELECT 1 FROM " + recipeUserTenantsTable + " already" + + " WHERE already.app_id = ? AND already.recipe_user_id = ? AND already.tenant_id = ?" + + " )", + pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, userId); + pst.setString(4, tenantIdentifier.getAppId()); + pst.setString(5, userId); + pst.setString(6, tenantIdentifier.getTenantId()); + pst.setString(7, tenantIdentifier.getAppId()); + pst.setString(8, userId); + pst.setString(9, tenantIdentifier.getTenantId()); + }, + rs -> { + String firstConflictType = null; + while (rs.next()) { + String conflictType = rs.getString("account_info_type"); + if (firstConflictType == null) { + firstConflictType = conflictType; + } + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(conflictType)) { + return conflictType; + } + } + return firstConflictType; + }); + } + + 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) + throws DuplicateEmailException, DuplicatePhoneNumberException, DuplicateThirdPartyUserException { + if (accountInfoType == null) { + return; + } + if (ACCOUNT_INFO_TYPE.THIRD_PARTY.toString().equals(accountInfoType)) { + throw new DuplicateThirdPartyUserException(); + } + 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).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); + }); + } + + 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)" + + " REFERENCES " + Config.getConfig(start).getAppsTable() + " (app_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 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() + "(app_id, primary_user_id);"; + } + + public static void addPrimaryUserAccountInfo_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, String userId) throws + StorageQueryException { + try { + String QUERY = "INSERT INTO " + getConfig(start).getPrimaryUserTenantsTable() + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT app_id, tenant_id, account_info_type, account_info_value, ?" + + " FROM " + getConfig(start).getRecipeUserTenantsTable() + + " WHERE app_id = ? AND recipe_user_id = ?"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, userId); // primary_user_id + pst.setString(2, appIdentifier.getAppId()); + pst.setString(3, userId); // recipe_user_id + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void checkIfLoginMethodCanBecomePrimary_Transaction(Start start, TransactionConnection con, AppIdentifier appIdentifier, LoginMethod loginMethod) + throws AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, StorageQueryException, SQLException { + Connection sqlCon = (Connection) con.getConnection(); + + // Build the query dynamically based on which values are not null + StringBuilder QUERY = new StringBuilder("SELECT primary_user_id, account_info_type FROM " + getConfig(start).getPrimaryUserTenantsTable()); + QUERY.append(" WHERE app_id = ?"); + + // Add placeholders for tenant IDs only if present + List tenantIds = new ArrayList<>(loginMethod.tenantIds); + if (!tenantIds.isEmpty()) { + QUERY.append(" AND tenant_id IN ("); + for (int i = 0; i < tenantIds.size(); i++) { + QUERY.append("?"); + if (i != tenantIds.size() - 1) { + QUERY.append(","); + } + } + QUERY.append(")"); + } + QUERY.append(" AND ("); + + // Build OR conditions for account info types + List orConditions = new ArrayList<>(); + List parameters = new ArrayList<>(); + + // Add app_id parameter + parameters.add(appIdentifier.getAppId()); + + // Add tenant_id parameters only if we add tenant_id filter to the query + if (!tenantIds.isEmpty()) { + parameters.addAll(tenantIds); + } + + // Email condition + if (loginMethod.email != null) { + orConditions.add("(account_info_type = ? AND account_info_value = ?)"); + parameters.add(ACCOUNT_INFO_TYPE.EMAIL.toString()); + parameters.add(loginMethod.email); + } + + // Phone condition + if (loginMethod.phoneNumber != null) { + orConditions.add("(account_info_type = ? AND account_info_value = ?)"); + parameters.add(ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString()); + parameters.add(loginMethod.phoneNumber); + } + + // Third party condition + if (loginMethod.thirdParty != null) { + String thirdPartyAccountInfoValue = loginMethod.thirdParty.id + "::" + loginMethod.thirdParty.userId; + orConditions.add("(account_info_type = ? AND account_info_value = ?)"); + parameters.add(ACCOUNT_INFO_TYPE.THIRD_PARTY.toString()); + parameters.add(thirdPartyAccountInfoValue); + } + + // If no OR conditions, return early (nothing to check) + if (orConditions.isEmpty()) { + return; + } + + // Join OR conditions + for (int i = 0; i < orConditions.size(); i++) { + QUERY.append(orConditions.get(i)); + if (i != orConditions.size() - 1) { + QUERY.append(" OR "); + } + } + + QUERY.append(") LIMIT 1"); + + String finalQuery = QUERY.toString(); + + // Execute query and check for results + String[] result = execute(sqlCon, finalQuery, pst -> { + for (int i = 0; i < parameters.size(); i++) { + pst.setObject(i + 1, parameters.get(i)); + } + }, rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id"), rs.getString("account_info_type")}; + } + return null; + }); + + if (result != null) { + String primaryUserId = 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"; + } + + throw new AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException(primaryUserId, message); + } + } + + public static void checkIfLoginMethodsCanBeLinked_Transaction(Start start, TransactionConnection con, AppIdentifier appIdentifier, Set tenantIds, Set emails, + Set phoneNumbers, Set thirdParties, String primaryUserId) throws AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, StorageQueryException, SQLException { + Connection sqlCon = (Connection) con.getConnection(); + + // If no account info to check, return early + if ((emails == null || emails.isEmpty()) && + (phoneNumbers == null || phoneNumbers.isEmpty()) && + (thirdParties == null || thirdParties.isEmpty())) { + return; + } + + // Build OR conditions for account info types + List orConditions = new ArrayList<>(); + List parameters = new ArrayList<>(); + + // Add app_id parameter + parameters.add(appIdentifier.getAppId()); + + List tenantIdsList = tenantIds == null ? new ArrayList<>() : new ArrayList<>(tenantIds); + // Add tenant_id parameters only if we add tenant_id filter to the query + if (!tenantIdsList.isEmpty()) { + parameters.addAll(tenantIdsList); + } + + // Add primary_user_id parameter (to exclude) + parameters.add(primaryUserId); + + // Email conditions + if (emails != null && !emails.isEmpty()) { + StringBuilder emailCondition = new StringBuilder("(account_info_type = ? AND account_info_value IN ("); + for (int i = 0; i < emails.size(); i++) { + emailCondition.append("?"); + if (i != emails.size() - 1) { + emailCondition.append(","); + } + } + emailCondition.append("))"); + orConditions.add(emailCondition.toString()); + parameters.add(ACCOUNT_INFO_TYPE.EMAIL.toString()); + parameters.addAll(emails); + } + + // Phone number conditions + if (phoneNumbers != null && !phoneNumbers.isEmpty()) { + StringBuilder phoneCondition = new StringBuilder("(account_info_type = ? AND account_info_value IN ("); + for (int i = 0; i < phoneNumbers.size(); i++) { + phoneCondition.append("?"); + if (i != phoneNumbers.size() - 1) { + phoneCondition.append(","); + } + } + phoneCondition.append("))"); + orConditions.add(phoneCondition.toString()); + parameters.add(ACCOUNT_INFO_TYPE.PHONE_NUMBER.toString()); + parameters.addAll(phoneNumbers); + } + + // Third party conditions + if (thirdParties != null && !thirdParties.isEmpty()) { + List thirdPartyValues = new ArrayList<>(); + for (LoginMethod.ThirdParty tp : thirdParties) { + thirdPartyValues.add(tp.id + "::" + tp.userId); + } + + StringBuilder thirdPartyCondition = new StringBuilder("(account_info_type = ? AND account_info_value IN ("); + for (int i = 0; i < thirdPartyValues.size(); i++) { + thirdPartyCondition.append("?"); + if (i != thirdPartyValues.size() - 1) { + thirdPartyCondition.append(","); + } + } + thirdPartyCondition.append("))"); + orConditions.add(thirdPartyCondition.toString()); + parameters.add(ACCOUNT_INFO_TYPE.THIRD_PARTY.toString()); + parameters.addAll(thirdPartyValues); + } + + // If no OR conditions, return early (shouldn't happen due to early return above) + if (orConditions.isEmpty()) { + return; + } + + // Build the full query + StringBuilder QUERY = new StringBuilder("SELECT primary_user_id, account_info_type, account_info_value FROM "); + QUERY.append(getConfig(start).getPrimaryUserTenantsTable()); + QUERY.append(" WHERE app_id = ?"); + if (!tenantIdsList.isEmpty()) { + QUERY.append(" AND tenant_id IN ("); + for (int i = 0; i < tenantIdsList.size(); i++) { + QUERY.append("?"); + if (i != tenantIdsList.size() - 1) { + QUERY.append(","); + } + } + QUERY.append(")"); + } + QUERY.append(" AND primary_user_id != ? AND ("); + + // Join OR conditions + for (int i = 0; i < orConditions.size(); i++) { + QUERY.append(orConditions.get(i)); + if (i != orConditions.size() - 1) { + QUERY.append(" OR "); + } + } + + QUERY.append(") LIMIT 1"); + + String finalQuery = QUERY.toString(); + + // Execute query and check for results + String[] result = execute(sqlCon, finalQuery, pst -> { + for (int i = 0; i < parameters.size(); i++) { + pst.setObject(i + 1, parameters.get(i)); + } + }, rs -> { + if (rs.next()) { + return new String[]{rs.getString("primary_user_id"), rs.getString("account_info_type"), rs.getString("account_info_value")}; + } + return null; + }); + + if (result != null) { + 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"; + } + + throw new AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException(conflictingPrimaryUserId, message); + } + } + + public static void reserveAccountInfoForLinking_Transaction(Start start, Connection sqlCon, AppIdentifier appIdentifier, + String recipeUserId, String primaryUserId) + throws SQLException { + /* + * When linking, the primary user's tenant set becomes the union of: + * - tenants currently associated with the primary user (via primary_user_tenants) + * - tenants currently associated with the recipe user (via recipe_user_tenants) + * + * We reserve account info in primary_user_tenants for the union tenant set by doing two passes: + * 1) recipe user's distinct account info x primary user's distinct tenants + * 2) primary user's distinct account info x recipe user's distinct tenants + * + * We must not use ON CONFLICT DO NOTHING. Use INSERT ... SELECT ... WHERE NOT EXISTS. + */ + + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + // 1) recipe user's account info -> all tenants of primary user + String QUERY_1 = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT ?, primary_tenants.tenant_id, recipe_ai.account_info_type, recipe_ai.account_info_value, ?" + + " FROM (" + + " SELECT DISTINCT tenant_id FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id = ?" + + " ) primary_tenants," + + " (" + + " SELECT DISTINCT account_info_type, account_info_value FROM " + recipeUserTenantsTable + + " WHERE app_id = ? AND recipe_user_id = ?" + + " ) recipe_ai" + + " WHERE NOT EXISTS (" + + " SELECT 1 FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ?" + + " AND p.tenant_id = primary_tenants.tenant_id" + + " AND p.account_info_type = recipe_ai.account_info_type" + + " AND p.account_info_value = recipe_ai.account_info_value" + + " )"; + + update(sqlCon, QUERY_1, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, primaryUserId); + pst.setString(5, appIdentifier.getAppId()); + pst.setString(6, recipeUserId); + pst.setString(7, appIdentifier.getAppId()); + }); + + // 2) primary user's account info -> all tenants of recipe user + String QUERY_2 = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT ?, recipe_tenants.tenant_id, primary_ai.account_info_type, primary_ai.account_info_value, ?" + + " FROM (" + + " SELECT DISTINCT tenant_id FROM " + recipeUserTenantsTable + + " WHERE app_id = ? AND recipe_user_id = ?" + + " ) recipe_tenants," + + " (" + + " SELECT DISTINCT account_info_type, account_info_value FROM " + primaryUserTenantsTable + + " WHERE app_id = ? AND primary_user_id = ?" + + " ) primary_ai" + + " WHERE NOT EXISTS (" + + " SELECT 1 FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ?" + + " AND p.tenant_id = recipe_tenants.tenant_id" + + " AND p.account_info_type = primary_ai.account_info_type" + + " AND p.account_info_value = primary_ai.account_info_value" + + " )"; + + update(sqlCon, QUERY_2, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, appIdentifier.getAppId()); + pst.setString(4, recipeUserId); + pst.setString(5, appIdentifier.getAppId()); + pst.setString(6, primaryUserId); + pst.setString(7, appIdentifier.getAppId()); + }); + } + + public static void addTenantIdToRecipeUser_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) + throws StorageQueryException, DuplicateEmailException, DuplicateThirdPartyUserException, DuplicatePhoneNumberException { + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + // Pre-check conflicts before attempting the INSERT + try { + String accountInfoType = getRecipeUserTenantsConflictTypeForAddTenant(sqlCon, recipeUserTenantsTable, tenantIdentifier, userId); + throwRecipeUserTenantsConflict(accountInfoType); + } catch (SQLException lookupError) { + throw new StorageQueryException(lookupError); + } + + /* + * Duplicate all existing recipe_user_tenants rows for this recipe user into the new tenant. + * + * If the recipe user is already associated with this tenant (i.e. any row exists for (app_id, tenant_id, recipe_user_id)), + * then do nothing. + * + * NOTE: We intentionally do NOT use "ON CONFLICT DO NOTHING" here because the table's primary key does not include + * recipe_user_id, so ON CONFLICT could hide genuine collisions (e.g. account info already belongs to another user). + */ + 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 " + recipeUserTenantsTable + " r" + + " WHERE r.app_id = ? AND r.recipe_user_id = ? AND r.tenant_id <> ?" + + " AND NOT EXISTS (" + + " SELECT 1 FROM " + recipeUserTenantsTable + " e" + + " WHERE e.app_id = ? AND e.recipe_user_id = ? AND e.tenant_id = ?" + + " )"; + + try { + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getTenantId()); + pst.setString(2, tenantIdentifier.getAppId()); + pst.setString(3, userId); + pst.setString(4, tenantIdentifier.getTenantId()); + pst.setString(5, tenantIdentifier.getAppId()); + pst.setString(6, userId); + pst.setString(7, tenantIdentifier.getTenantId()); + }); + } 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 primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + + // Pre-check conflicts before attempting the INSERT + try { + String[] conflict = getPrimaryUserTenantsConflictForAddTenant(sqlCon, primaryUserTenantsTable, tenantIdentifier, supertokensUserId); + throwPrimaryUserTenantsConflict(conflict); + } catch (SQLException lookupError) { + throw new StorageQueryException(lookupError); + } + + /* + * Duplicate all existing primary_user_tenants rows for this primary user into the new tenant. + * + * If the primary user is already associated with this tenant (i.e. any row exists for (app_id, tenant_id, primary_user_id)), + * then do nothing. + * + * NOTE: We intentionally do NOT use "ON CONFLICT DO NOTHING" here because the table's primary key does not include + * primary_user_id, so ON CONFLICT could hide genuine collisions (e.g. account info already belongs to another primary user). + */ + String QUERY = "INSERT INTO " + primaryUserTenantsTable + + " (app_id, tenant_id, account_info_type, account_info_value, primary_user_id)" + + " SELECT DISTINCT p.app_id, ?, p.account_info_type, p.account_info_value, ?" + + " FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.primary_user_id = ? AND p.tenant_id <> ?" + + " AND NOT EXISTS (" + + " SELECT 1 FROM " + primaryUserTenantsTable + " e" + + " WHERE e.app_id = ? AND e.primary_user_id = ? AND e.tenant_id = ?" + + " )"; + + try { + update(sqlCon, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getTenantId()); + pst.setString(2, supertokensUserId); + pst.setString(3, tenantIdentifier.getAppId()); + pst.setString(4, supertokensUserId); + pst.setString(5, tenantIdentifier.getTenantId()); + pst.setString(6, tenantIdentifier.getAppId()); + pst.setString(7, supertokensUserId); + pst.setString(8, tenantIdentifier.getTenantId()); + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + + } + + public static void removeAccountInfoForRecipeUser_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 removeAccountInfoForPrimaryUserIfNecessary_Transaction(Start start, Connection sqlCon, TenantIdentifier tenantIdentifier, String userId) throws StorageQueryException { + try { + // If this recipe user is not linked / not a primary user, there is no entry in primary_user_tenants to clean up. + String appIdToUserIdTable = getConfig(start).getAppIdToUserIdTable(); + String[] linkingInfo = execute(sqlCon, + "SELECT primary_or_recipe_user_id, is_linked_or_is_a_primary_user FROM " + appIdToUserIdTable + + " WHERE app_id = ? AND user_id = ?", + pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + }, + rs -> { + if (!rs.next()) { + return null; + } + return new String[]{ + rs.getString("primary_or_recipe_user_id"), + String.valueOf(rs.getBoolean("is_linked_or_is_a_primary_user")) + }; + }); + + if (linkingInfo == null) { + return; + } + + String primaryUserId = linkingInfo[0]; + boolean isLinkedOrPrimary = Boolean.parseBoolean(linkingInfo[1]); + if (!isLinkedOrPrimary) { + return; + } + + /* + * Remove account info rows for this primary user in the tenant if (and only if) there is no + * linked recipe user (including the primary user itself) that still has the same account info in + * recipe_user_tenants for this tenant. + */ + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + // 1. Remove account info that is not contributed by any other linked user. + String QUERY_1 = "DELETE FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.tenant_id = ? AND p.primary_user_id = ?" + + " AND NOT EXISTS (" + + " SELECT 1" + + " FROM " + recipeUserTenantsTable + " r" + + " JOIN " + appIdToUserIdTable + " a" + + " ON a.app_id = r.app_id AND a.user_id = r.recipe_user_id" + + " WHERE r.app_id = p.app_id" + + " AND r.tenant_id = p.tenant_id" + + " AND r.account_info_type = p.account_info_type" + + " AND r.account_info_value = p.account_info_value" + + " AND a.primary_or_recipe_user_id = ?" + + " AND a.is_linked_or_is_a_primary_user = true" + + " AND r.recipe_user_id <> ?" + + " )"; + + update(sqlCon, QUERY_1, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, primaryUserId); + pst.setString(4, primaryUserId); + pst.setString(5, userId); + }); + + // 2. Remove tenant id that is not contributed by any other linked user. + String QUERY_2 = "DELETE FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.tenant_id = ? AND p.primary_user_id = ?" + + " AND NOT EXISTS (" + + " SELECT 1" + + " FROM " + recipeUserTenantsTable + " r" + + " JOIN " + appIdToUserIdTable + " a" + + " ON a.app_id = r.app_id AND a.user_id = r.recipe_user_id" + + " WHERE r.app_id = p.app_id" + + " AND r.tenant_id = p.tenant_id" + + " AND a.primary_or_recipe_user_id = ?" + + " AND a.is_linked_or_is_a_primary_user = true" + + " AND r.recipe_user_id <> ?" + + " )"; + + update(sqlCon, QUERY_2, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, primaryUserId); + pst.setString(4, primaryUserId); + pst.setString(5, userId); + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void removeAccountInfoForPrimaryUserIfNecessary_Transaction(Start start, Connection sqlCon, AppIdentifier tenantIdentifier, String userId) throws StorageQueryException { + try { + // If this recipe user is not linked / not a primary user, there is no entry in primary_user_tenants to clean up. + String appIdToUserIdTable = getConfig(start).getAppIdToUserIdTable(); + String[] linkingInfo = execute(sqlCon, + "SELECT primary_or_recipe_user_id, is_linked_or_is_a_primary_user FROM " + appIdToUserIdTable + + " WHERE app_id = ? AND user_id = ?", + pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + }, + rs -> { + if (!rs.next()) { + return null; + } + return new String[]{ + rs.getString("primary_or_recipe_user_id"), + String.valueOf(rs.getBoolean("is_linked_or_is_a_primary_user")) + }; + }); + + if (linkingInfo == null) { + return; + } + + String primaryUserId = linkingInfo[0]; + boolean isLinkedOrPrimary = Boolean.parseBoolean(linkingInfo[1]); + if (!isLinkedOrPrimary) { + return; + } + + /* + * App-scoped cleanup (across all tenants): + * + * 1) Remove account info rows for this primary user for which there is no other linked recipe user + * that still has the same account info in that tenant. + * 2) Remove tenant associations (i.e. all rows for that tenant) for which there is no other linked + * recipe user that has any account info in that tenant. + */ + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + // 1. Remove account info that is not contributed by any other linked user. + String QUERY_1 = "DELETE FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.primary_user_id = ?" + + " AND NOT EXISTS (" + + " SELECT 1" + + " FROM " + recipeUserTenantsTable + " r" + + " JOIN " + appIdToUserIdTable + " a" + + " ON a.app_id = r.app_id AND a.user_id = r.recipe_user_id" + + " WHERE r.app_id = p.app_id" + + " AND r.tenant_id = p.tenant_id" + + " AND r.account_info_type = p.account_info_type" + + " AND r.account_info_value = p.account_info_value" + + " AND a.primary_or_recipe_user_id = ?" + + " AND a.is_linked_or_is_a_primary_user = true" + + " AND r.recipe_user_id <> ?" + + " )"; + + update(sqlCon, QUERY_1, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, primaryUserId); + pst.setString(4, userId); + }); + + // 2. Remove tenant id that is not contributed by any other linked user. + String QUERY_2 = "DELETE FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.primary_user_id = ?" + + " AND NOT EXISTS (" + + " SELECT 1" + + " FROM " + recipeUserTenantsTable + " r" + + " JOIN " + appIdToUserIdTable + " a" + + " ON a.app_id = r.app_id AND a.user_id = r.recipe_user_id" + + " WHERE r.app_id = p.app_id" + + " AND r.tenant_id = p.tenant_id" + + " AND a.primary_or_recipe_user_id = ?" + + " AND a.is_linked_or_is_a_primary_user = true" + + " AND r.recipe_user_id <> ?" + + " )"; + + update(sqlCon, QUERY_2, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, primaryUserId); + pst.setString(4, userId); + }); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + public static void removeAccountInfoReservations_Transaction(Start start, TransactionConnection con, + AppIdentifier appIdentifier, String userId) + throws StorageQueryException { + try { + Connection sqlCon = (Connection) con.getConnection(); + + String appIdToUserIdTable = getConfig(start).getAppIdToUserIdTable(); + String primaryUserTenantsTable = getConfig(start).getPrimaryUserTenantsTable(); + String recipeUserTenantsTable = getConfig(start).getRecipeUserTenantsTable(); + + /* + * If this user was linked (or was itself a primary user), we may have "reserved" account info in + * primary_user_tenants for the user's primary. + * + * We only remove the primary_user_tenants rows corresponding to this user's account infos (and only if no + * other linked recipe user for the same primary still has that account info in that tenant). + * + * NOTE: We intentionally do NOT run a broader "orphan cleanup" for the whole primary user here. + */ + String[] linkingInfo = execute(sqlCon, + "SELECT primary_or_recipe_user_id, is_linked_or_is_a_primary_user FROM " + appIdToUserIdTable + + " WHERE app_id = ? AND user_id = ?", + pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, userId); + }, + rs -> { + if (!rs.next()) { + return null; + } + return new String[]{ + rs.getString("primary_or_recipe_user_id"), + String.valueOf(rs.getBoolean("is_linked_or_is_a_primary_user")) + }; + }); + + if (linkingInfo == null) { + return; + } + + String primaryUserId = linkingInfo[0]; + boolean isLinkedOrPrimary = Boolean.parseBoolean(linkingInfo[1]); + if (isLinkedOrPrimary) { + /* + * Remove only the primary_user_tenants rows corresponding to this user's account infos. + * + * IMPORTANT: We must not delete all rows where primary_user_id = userId, since other recipe users can + * stay linked to the same primary user ID. + */ + { + String QUERY = "DELETE FROM " + primaryUserTenantsTable + " p" + + " WHERE p.app_id = ? AND p.primary_user_id = ?" + + " AND EXISTS (" + + " SELECT 1 FROM " + recipeUserTenantsTable + " r_me" + + " WHERE r_me.app_id = p.app_id" + + " AND r_me.recipe_user_id = ?" + + " AND r_me.tenant_id = p.tenant_id" + + " AND r_me.account_info_type = p.account_info_type" + + " AND r_me.account_info_value = p.account_info_value" + + " )" + + " AND NOT EXISTS (" + + " SELECT 1" + + " FROM " + recipeUserTenantsTable + " r" + + " JOIN " + appIdToUserIdTable + " a" + + " ON a.app_id = r.app_id AND a.user_id = r.recipe_user_id" + + " WHERE r.app_id = p.app_id" + + " AND r.tenant_id = p.tenant_id" + + " AND r.account_info_type = p.account_info_type" + + " AND r.account_info_value = p.account_info_value" + + " AND a.primary_or_recipe_user_id = ?" + + " AND a.is_linked_or_is_a_primary_user = true" + + " AND r.recipe_user_id <> ?" + + " )"; + + update(sqlCon, QUERY, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, primaryUserId); + pst.setString(3, userId); + pst.setString(4, primaryUserId); + pst.setString(5, userId); + }); + } + } + + /* + * Finally, delete the user's own account info rows from recipe_user_tenants at app_id scope. + * (We do this at the end since the primary_user_tenants cleanup above consults recipe_user_tenants.) + */ + { + 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); + }); + } + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } +} 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..50a89ec8 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/EmailPasswordQueries.java @@ -16,6 +16,21 @@ 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 io.supertokens.pluginInterface.ACCOUNT_INFO_TYPE; +import static io.supertokens.pluginInterface.RECIPE_ID.EMAIL_PASSWORD; import io.supertokens.pluginInterface.RowMapper; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; @@ -27,20 +42,13 @@ 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) { @@ -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(?, ?, ?, ?, ?)"; 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..67c3c36d 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,23 @@ 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).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.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 +796,9 @@ public static void deleteAllTables(Start start) throws SQLException, StorageQuer + getConfig(start).getKeyValueTable() + "," + getConfig(start).getAppIdToUserIdTable() + "," + getConfig(start).getUserIdMappingTable() + "," + + getConfig(start).getRecipeUserTenantsTable() + "," + getConfig(start).getUsersTable() + "," + + getConfig(start).getPrimaryUserTenantsTable() + "," + getConfig(start).getAccessTokenSigningKeysTable() + "," + getConfig(start).getTenantFirstFactorsTable() + "," + getConfig(start).getTenantRequiredSecondaryFactorsTable() + "," @@ -875,8 +894,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()); @@ -1929,7 +1952,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..efce6d9a 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/PasswordlessQueries.java @@ -16,6 +16,23 @@ 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 io.supertokens.pluginInterface.ACCOUNT_INFO_TYPE; +import static io.supertokens.pluginInterface.RECIPE_ID.PASSWORDLESS; import io.supertokens.pluginInterface.RowMapper; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; @@ -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); 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..5c80b065 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/ThirdPartyQueries.java @@ -16,6 +16,20 @@ 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 io.supertokens.pluginInterface.ACCOUNT_INFO_TYPE; +import static io.supertokens.pluginInterface.RECIPE_ID.THIRD_PARTY; import io.supertokens.pluginInterface.RowMapper; import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.authRecipe.LoginMethod; @@ -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, "", "", + thirdParty.id + "::" + thirdParty.userId); + } + { // thirdparty_users String QUERY = "INSERT INTO " + getConfig(start).getThirdPartyUsersTable() + "(app_id, third_party_id, third_party_user_id, user_id, email, time_joined)" 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 b620d884..3900991e 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..8aa427f2 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.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/DeadlockTest.java b/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java index 58d5e088..c37e2400 100644 --- a/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java +++ b/src/test/java/io/supertokens/storage/postgresql/test/DeadlockTest.java @@ -519,7 +519,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);