diff --git a/.github/workflows/ci-keycloak-ydb-extension.yaml b/.github/workflows/ci-keycloak-ydb-extension.yaml new file mode 100644 index 00000000..a309f863 --- /dev/null +++ b/.github/workflows/ci-keycloak-ydb-extension.yaml @@ -0,0 +1,39 @@ +name: Keycloak YDB Extension CI with Maven + +on: + push: + branches: + - main + paths: + - 'keycloak-ydb-extension/**' + - '.github/workflows/ci-keycloak-ydb-extension.yaml' + pull_request: + paths: + - 'keycloak-ydb-extension/**' + - '.github/workflows/ci-keycloak-ydb-extension.yaml' + +env: + MAVEN_ARGS: --batch-mode --update-snapshots -Dstyle.color=always + +jobs: + build: + name: Keycloak YDB Extension + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Download dependencies + working-directory: ./keycloak-ydb-extension + run: mvn $MAVEN_ARGS dependency:go-offline + + - name: Build & test (unit + Keycloak model tests with YDB) + working-directory: ./keycloak-ydb-extension + run: mvn $MAVEN_ARGS verify diff --git a/keycloak-ydb-extension/.gitignore b/keycloak-ydb-extension/.gitignore new file mode 100644 index 00000000..f80243a7 --- /dev/null +++ b/keycloak-ydb-extension/.gitignore @@ -0,0 +1,4 @@ +dependency-reduced-pom.xml + +# jar files of keycloak-ydb-extension +docker/providers diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md new file mode 100644 index 00000000..fdb3c925 --- /dev/null +++ b/keycloak-ydb-extension/README.md @@ -0,0 +1,51 @@ +# Keycloak YDB extension + +## Overview + +Keycloak extension to use [YDB](https://ydb.tech/) as the main database. Keycloak does not support YDB as a built-in database (see [Configuring the database](https://www.keycloak.org/server/db)); this extension provides the JDBC driver and Hibernate dialect. + +## Configuration + +When using YDB you must avoid giving the YDB URL to Keycloak’s default datasource (it would fail with “Driver does not support the provided URL”). Use: + +| Variable | Value | Purpose | +|----------|--------|---------| +| `KC_DB` | `dev-file` | Built-in datasource uses dev-file; it never sees the YDB URL. | +| `KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL` | `jdbc:ydb:grpc://host:2136/database` | JDBC URL used by this extension (required). | +| `KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED` | `false` | Disables the default Quarkus JPA provider so H2 is not used. | +| `KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED` | `false` | Disables the default Quarkus Liquibase provider (migrations are run by this extension). | + +The extension is enabled when its JAR is in the `providers` directory and the YDB JDBC URL is configured. No separate “enable” flag is required. + +### Connection Pool (HikariCP) + +| Variable | Default | Description | +|----------|---------|-------------| +| `KC_SPI_CONNECTIONS_JPA_YDB_POOL_SIZE` | `50` | Maximum pool size | +| `KC_SPI_CONNECTIONS_JPA_YDB_MIN_IDLE` | `10` | Minimum idle connections | +| `KC_SPI_CONNECTIONS_JPA_YDB_CONNECTION_TIMEOUT` | `30000` | Connection timeout (ms) | +| `KC_SPI_CONNECTIONS_JPA_YDB_IDLE_TIMEOUT` | `600000` | Idle connection timeout (ms) | +| `KC_SPI_CONNECTIONS_JPA_YDB_MAX_LIFETIME` | `1800000` | Max connection lifetime (ms) | + +Or in `keycloak.conf`: + +```properties +spi-connections-jpa-ydb-pool-size=50 +spi-connections-jpa-ydb-min-idle=10 +``` + +## Getting started + +1. Build and package the extension, then put the JAR in Keycloak’s `providers` directory (or use the Docker setup below). +2. Set the environment variables above and start Keycloak. + +### Local development with Docker + +From the project root: + +```bash +./run-keycloak-with-ydb.sh +``` + +This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. +All Keycloak options (YDB URL, admin user, etc.) are set in `docker/conf/keycloak.conf`; override with environment variables if needed. \ No newline at end of file diff --git a/keycloak-ydb-extension/core/pom.xml b/keycloak-ydb-extension/core/pom.xml new file mode 100644 index 00000000..200466fb --- /dev/null +++ b/keycloak-ydb-extension/core/pom.xml @@ -0,0 +1,212 @@ + + + 4.0.0 + + + tech.ydb + keycloak-ydb-extension-parent + 1.0-SNAPSHOT + ../pom.xml + + + jar + keycloak-ydb-extension (core) + keycloak-ydb-extension + 1.0-SNAPSHOT + + + src/main/kotlin + src/test/kotlin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + + default-compile + none + + + default-testCompile + none + + + java-compile + + compile + + compile + + + java-test-compile + + testCompile + + test-compile + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + + + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + org.jetbrains.kotlin:kotlin-stdlib + org.jetbrains.kotlin:kotlin-reflect + org.jetbrains.kotlin:* + + tech.ydb.jdbc:* + tech.ydb:* + tech.ydb.dialects:* + com.zaxxer:HikariCP + io.r2dbc:* + + + + + + + + + + + + + + + + + org.keycloak + keycloak-parent + ${keycloak.version} + pom + import + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-server-spi-private + provided + + + org.keycloak + keycloak-model-jpa + provided + + + + + tech.ydb.jdbc + ydb-jdbc-driver + ${ydb-jdbc-driver.version} + + + + tech.ydb.dialects + liquibase-ydb-dialect + 1.1.1 + + + + tech.ydb.dialects + hibernate-ydb-dialect-v7 + ${hibernate-v7.ydb.dialect.version} + + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + + + org.junit.jupiter + junit-jupiter + test + + + tech.ydb.test + ydb-junit5-support + 2.3.27 + test + + + org.mockito.kotlin + mockito-kotlin + 6.1.0 + test + + + org.jboss.resteasy + resteasy-core + test + + + diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt new file mode 100644 index 00000000..68f72dcb --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -0,0 +1,71 @@ +package tech.ydb.keycloak.client + +import org.keycloak.Config +import org.keycloak.authorization.fgap.AdminPermissionsSchema +import org.keycloak.common.Profile +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.models.ClientProvider +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.models.RealmModel.RealmAttributeUpdateEvent +import org.keycloak.models.jpa.JpaClientProviderFactory +import org.keycloak.models.jpa.entities.RealmAttributes +import org.keycloak.protocol.saml.SamlConfigAttributes +import org.keycloak.provider.ProviderEvent +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY +import tech.ydb.keycloak.realm.YdbRealmProvider + +class YdbClientProviderFactory : JpaClientProviderFactory() { + + private lateinit var clientSearchableAttributes: Set + + override fun init(config: Config.Scope) { + var searchableAttrsArr = config.getArray("searchableAttributes")?.toList() + if (searchableAttrsArr == null) { + val s = System.getProperty("keycloak.client.searchableAttributes") + searchableAttrsArr = s?.split("\\s*,\\s*".toRegex()) + } + val s = HashSet(REQUIRED_SEARCHABLE_ATTRIBUTES) + if (searchableAttrsArr != null) { + s.addAll(searchableAttrsArr) + } + clientSearchableAttributes = s.toSet() + } + + override fun postInit(factory: KeycloakSessionFactory) { + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) { + factory.register { event: ProviderEvent? -> + if (event is RealmAttributeUpdateEvent) { + if (event.attributeName == RealmAttributes.ADMIN_PERMISSIONS_ENABLED && event.attributeValue.toBoolean()) { + val keycloakSession = event.keycloakSession + val realm = event.realm + AdminPermissionsSchema.SCHEMA.init(keycloakSession, realm) + } + } + } + } + } + + override fun create(session: KeycloakSession): ClientProvider { + val em = session.getProvider(JpaConnectionProvider::class.java).entityManager + return YdbRealmProvider(session, em, clientSearchableAttributes, null) + } + + override fun close() { + // no operations + } + + override fun order(): Int = PROVIDER_PRIORITY + + override fun getId(): String = PROVIDER_ID + + private companion object{ + private val REQUIRED_SEARCHABLE_ATTRIBUTES = listOf( + "saml_idp_initiated_sso_url_name", + SamlConfigAttributes.SAML_ARTIFACT_BINDING_IDENTIFIER, + "jwt.credential.issuer", + "jwt.credential.sub" + ) + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt new file mode 100644 index 00000000..ed2b416e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt @@ -0,0 +1,20 @@ +package tech.ydb.keycloak.client + +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.models.ClientScopeProvider +import org.keycloak.models.KeycloakSession +import org.keycloak.models.jpa.JpaClientScopeProviderFactory +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY +import tech.ydb.keycloak.realm.YdbRealmProvider + +class YdbClientScopeProviderFactory : JpaClientScopeProviderFactory() { + override fun create(session: KeycloakSession): ClientScopeProvider { + val em = session.getProvider(JpaConnectionProvider::class.java).entityManager + return YdbRealmProvider(session, em, null, null) + } + + override fun order(): Int = PROVIDER_PRIORITY + + override fun getId(): String = PROVIDER_ID +} \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt new file mode 100644 index 00000000..725b63f6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt @@ -0,0 +1,6 @@ +package tech.ydb.keycloak.config + +object ProviderConfig { + const val PROVIDER_PRIORITY = Int.MAX_VALUE + const val PROVIDER_ID = "ydb" +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt new file mode 100644 index 00000000..954915a6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -0,0 +1,347 @@ +package tech.ydb.keycloak.connection + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import jakarta.persistence.EntityManagerFactory +import jakarta.persistence.SynchronizationType.SYNCHRONIZED +import liquibase.GlobalConfiguration +import org.hibernate.cfg.AvailableSettings +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.ServerStartupError +import org.keycloak.connections.jpa.DefaultJpaConnectionProvider +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.connections.jpa.JpaConnectionProviderFactory +import org.keycloak.connections.jpa.support.EntityManagerProxy +import org.keycloak.connections.jpa.updater.JpaUpdaterProvider +import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY +import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.VALID +import org.keycloak.connections.jpa.util.JpaUtils +import org.keycloak.migration.MigrationModelManager +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.models.dblock.DBLockManager +import org.keycloak.models.dblock.DBLockProvider +import org.keycloak.models.utils.KeycloakModelUtils +import org.keycloak.provider.ServerInfoAwareProviderFactory +import tech.ydb.hibernate.dialect.YdbDialect +import tech.ydb.hibernate.dialect.YdbSettings +import tech.ydb.jdbc.YdbDriver +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY +import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl.Companion.MigrationStrategy.* +import java.io.File +import java.sql.Connection +import java.sql.DriverManager +import java.util.* +import kotlin.properties.Delegates + +class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInfoAwareProviderFactory { + + private val logger: Logger = Logger.getLogger(YdbConnectionProviderFactoryImpl::class.java) + + private lateinit var config: Config.Scope + + private var jtaEnabled by Delegates.notNull() + private lateinit var jdbcUrl: String + + @Volatile + private lateinit var entityManagerFactory: EntityManagerFactory + + private lateinit var dataSource: HikariDataSource + + override fun create(session: KeycloakSession): JpaConnectionProvider { + val emf = getOrCreateEntityManagerFactory(session) + + val em = if (!jtaEnabled) { + logger.trace("enlisting EntityManager in JpaKeycloakTransaction") + emf.createEntityManager() + } else { + emf.createEntityManager(SYNCHRONIZED) + } + + val keycloakEm = EntityManagerProxy.create(session, em, true) + val ydbEm = YdbEntityManagerProxy.create(keycloakEm) + + if (!jtaEnabled) { + session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm)) + } + return DefaultJpaConnectionProvider(ydbEm) + } + + override fun init(scope: Config.Scope) { + config = scope + jdbcUrl = requireNotNull(config["jdbcUrl"]) { + "YDB JDBC URL is required" + } + } + + override fun postInit(factory: KeycloakSessionFactory) { + checkJtaEnabled(factory) + + val schema = getSchema() + connection.use { connection -> + factory.create().use { session -> + createOrUpdateSchema(schema, connection, session) + } + } + factory.create().use { session -> getOrCreateEntityManagerFactory(session) } + + KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) } + } + + private fun createOrUpdateSchema( + schema: String?, + connection: Connection, + session: KeycloakSession + ) { + val strategy: MigrationStrategy = getMigrationStrategy() + val initializeEmpty = config.getBoolean("initializeEmpty", true) + val databaseUpdateFile: File = getDatabaseUpdateFile() + + // actually it is QuarkusJpaUpdaterProvider and it works + val updater = session.getProvider(JpaUpdaterProvider::class.java) + + val status = updater.validate(connection, schema) + + if (status == VALID) { + logger.debug("Database is up-to-date") + } else if (status == EMPTY) { + if (initializeEmpty) { + update(connection, schema, session, updater) + } else { + when (strategy) { + UPDATE -> update(connection, schema, session, updater) + MANUAL -> { + export(connection, schema, databaseUpdateFile, session, updater) + throw ServerStartupError( + "Database not initialized, please initialize database with " + databaseUpdateFile.getAbsolutePath(), + false + ) + } + + VALIDATE -> throw ServerStartupError( + "Database not initialized, please enable database initialization", + false + ) + } + } + } else { + when (strategy) { + UPDATE -> update(connection, schema, session, updater) + MANUAL -> { + export(connection, schema, databaseUpdateFile, session, updater) + throw ServerStartupError( + "Database not up-to-date, please migrate database with " + databaseUpdateFile.getAbsolutePath(), + false + ) + } + + VALIDATE -> throw ServerStartupError( + "Database not up-to-date, please enable database migration", + false + ) + } + } + } + + override fun close() { + if (::entityManagerFactory.isInitialized) { + entityManagerFactory.close() + } + if (::dataSource.isInitialized) { + dataSource.close() + } + } + + override fun getId(): String = PROVIDER_ID + + override fun order(): Int = PROVIDER_PRIORITY + + override fun getConnection(): Connection { + try { + val driver = YdbDriver::class.java.name + Class.forName(driver) + return DriverManager.getConnection(jdbcUrl) + } catch (e: Exception) { + throw RuntimeException("Failed to connect to database", e) + } + } + + override fun getSchema(): String? { + val schema = config.get("schema")?.takeIf { it.isNotBlank() } + if (schema?.contains("-") == true + && !System.getProperty(GlobalConfiguration.PRESERVE_SCHEMA_CASE.key).toBoolean() + ) { + System.setProperty(GlobalConfiguration.PRESERVE_SCHEMA_CASE.key, "true") + logger.warnf( + "The passed schema '%s' contains a dash. Setting liquibase config option PRESERVE_SCHEMA_CASE to true. See https://github.com/keycloak/keycloak/issues/20870 for more information.", + schema + ) + } + return schema + } + + // TODO: can be added more info for info in admin console + override fun getOperationalInfo(): Map = mapOf( + "YDB" to "enabled", + ) + + + private fun checkJtaEnabled(factory: KeycloakSessionFactory) { + // for now, we do not need jta + // we will use resource local transaction + jtaEnabled = false + } + + private fun getOrCreateEntityManagerFactory(session: KeycloakSession): EntityManagerFactory { + if (::entityManagerFactory.isInitialized) { + return entityManagerFactory + } + synchronized(this) { + if (::entityManagerFactory.isInitialized) { + return entityManagerFactory + } + + val properties = buildPropertiesFromScope() + + entityManagerFactory = JpaUtils.createEntityManagerFactory(session, PERSISTENCE_UNIT_NAME, properties, jtaEnabled) + addSpecificNamedQueries() + logger.info("YDB EntityManagerFactory created via JpaUtils") + return entityManagerFactory + } + } + + /** + * Load YDB-specific named query overrides from META-INF/queries-ydb.properties. + * Follows the same pattern as DefaultJpaConnectionProviderFactory.addSpecificNamedQueries(). + */ + private fun addSpecificNamedQueries() = entityManagerFactory.createEntityManager().use { em -> + JpaUtils.loadSpecificNamedQueries(DB_KIND).forEach { (queryName, querySql) -> + JpaUtils.configureNamedQuery(queryName.toString(), querySql.toString(), em) + } + } + + private fun buildPropertiesFromScope(): MutableMap { + val properties = mutableMapOf() + + val hikariConfig = HikariConfig().apply { + jdbcUrl = this@YdbConnectionProviderFactoryImpl.jdbcUrl + driverClassName = YdbDriver::class.java.name + maximumPoolSize = config.getInt("poolSize", 50) + minimumIdle = config.getInt("minIdle", 10) + connectionTimeout = config.getLong("connectionTimeout", 30000) + idleTimeout = config.getLong("idleTimeout", 600000) + maxLifetime = config.getLong("maxLifetime", 1800000) + } + dataSource = HikariDataSource(hikariConfig) + logger.info("HikariCP pool created: maxSize=${hikariConfig.maximumPoolSize}, minIdle=${hikariConfig.minimumIdle}") + + properties[AvailableSettings.JAKARTA_NON_JTA_DATASOURCE] = dataSource + + properties[YdbSettings.IGNORE_LOCK_HINTS] = true + + getSchema()?.let { properties[JpaUtils.HIBERNATE_DEFAULT_SCHEMA] = it } + + properties["hibernate.dialect"] = YdbDialect::class.java.name + + properties["hibernate.show_sql"] = config.getBoolean("showSql", false) + properties["hibernate.format_sql"] = config.getBoolean("formatSql", true) + + val globalStatsInterval = config.getInt("globalStatsInterval", -1) + if (globalStatsInterval != -1) { + properties.put("hibernate.generate_statistics", true) + } + + val classLoaders = ArrayList() + + if (properties.containsKey(AvailableSettings.CLASSLOADERS)) { + classLoaders.addAll(properties.get(AvailableSettings.CLASSLOADERS) as Collection) + } + classLoaders.add(javaClass.classLoader) + properties.put(AvailableSettings.CLASSLOADERS, classLoaders) + + return properties + } + + private fun update( + connection: Connection?, + schema: String?, + session: KeycloakSession, + updater: JpaUpdaterProvider + ) { + KeycloakModelUtils.runJobInTransaction(session.keycloakSessionFactory) { lockSession -> + val dbLockManager = DBLockManager(lockSession) + val dbLock2 = dbLockManager.dbLock + dbLock2.waitForLock(DBLockProvider.Namespace.DATABASE) + try { + updater.update(connection, schema) + } finally { + dbLock2.releaseLock() + } + } + } + + private fun export( + connection: Connection?, + schema: String?, + databaseUpdateFile: File?, + session: KeycloakSession, + updater: JpaUpdaterProvider + ) { + KeycloakModelUtils.runJobInTransaction(session.keycloakSessionFactory) { lockSession -> + val dbLockManager = DBLockManager(lockSession) + val dbLock2 = dbLockManager.dbLock + dbLock2.waitForLock(DBLockProvider.Namespace.DATABASE) + try { + updater.export(connection, schema, databaseUpdateFile) + } finally { + dbLock2.releaseLock() + } + } + } + + private fun getMigrationStrategy(): MigrationStrategy { + var migrationStrategy = config.get("migrationStrategy") + if (migrationStrategy == null) { + // !!! comment from keycloak code + // Support 'databaseSchema' for backwards compatibility + migrationStrategy = config.get("databaseSchema") + } + + return if (migrationStrategy != null) { + MigrationStrategy.valueOf(migrationStrategy.uppercase(Locale.getDefault())) + } else { + UPDATE + } + } + + private fun migrateModel(session: KeycloakSession) { + // !!! comment from keycloak code + // Using a lock to prevent concurrent migration in concurrently starting nodes + val dbLockManager = DBLockManager(session) + val dbLock = dbLockManager.dbLock + dbLock.waitForLock(DBLockProvider.Namespace.DATABASE) + try { + KeycloakModelUtils.runJobInTransaction( + session.keycloakSessionFactory + ) { session: KeycloakSession? -> MigrationModelManager.migrate(session) } + } finally { + dbLock.releaseLock() + } + } + + private fun getDatabaseUpdateFile(): File { + val databaseUpdateFile = config.get("migrationExport", "keycloak-database-update.sql") + return File(databaseUpdateFile) + } + + private companion object { + private enum class MigrationStrategy { + UPDATE, VALIDATE, MANUAL + } + + const val PERSISTENCE_UNIT_NAME = "keycloak-default" + const val DB_KIND = "ydb" + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt new file mode 100644 index 00000000..c6a8f52e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt @@ -0,0 +1,43 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import org.jboss.logging.Logger +import tech.ydb.keycloak.utils.YdbRetryableResponses +import tech.ydb.keycloak.utils.isYdbRetryable +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +class YdbEntityManagerProxy(private val em: EntityManager) { + private fun invoke(proxy: Any, method: Method, args: Array?): Any? { + try { + return method.invoke(em, *(args ?: emptyArray())) + } catch (e: InvocationTargetException) { + val cause = e.cause ?: throw e + if (isYdbRetryable(cause)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw YdbRetryableResponses.toWebApplicationException(cause) + } + throw cause + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw YdbRetryableResponses.toWebApplicationException(e) + } + throw e + } + } + + companion object { + private val LOG: Logger = Logger.getLogger(YdbEntityManagerProxy::class.java) + + fun create(em: EntityManager): EntityManager { + val proxy = YdbEntityManagerProxy(em) + return Proxy.newProxyInstance( + EntityManager::class.java.classLoader, + arrayOf(EntityManager::class.java), + proxy::invoke + ) as EntityManager + } + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt new file mode 100644 index 00000000..2b3db18b --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt @@ -0,0 +1,30 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import org.jboss.logging.Logger +import org.keycloak.connections.jpa.JpaKeycloakTransaction +import tech.ydb.keycloak.utils.YdbRetryableResponses +import tech.ydb.keycloak.utils.isYdbRetryable + +class YdbJpaKeycloakTransaction(em: EntityManager) : JpaKeycloakTransaction(em) { + + override fun commit() { + try { + super.commit() + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during commit, returning 503") + throw YdbRetryableResponses.toWebApplicationException( + e, + "YDB transaction aborted", + YdbRetryableResponses.TRANSACTION_DESCRIPTION, + ) + } + throw e + } + } + + companion object { + private val LOG: Logger = Logger.getLogger(YdbJpaKeycloakTransaction::class.java) + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt new file mode 100644 index 00000000..35466e92 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt @@ -0,0 +1,41 @@ +package tech.ydb.keycloak.error + +import jakarta.persistence.PersistenceException +import jakarta.ws.rs.core.Context +import jakarta.ws.rs.core.Response +import jakarta.ws.rs.ext.ExceptionMapper +import jakarta.ws.rs.ext.Provider +import org.hibernate.exception.GenericJDBCException +import org.jboss.logging.Logger +import org.keycloak.models.KeycloakSession +import org.keycloak.services.error.KeycloakErrorHandler +import tech.ydb.keycloak.utils.YdbRetryableResponses +import tech.ydb.keycloak.utils.isYdbRetryable + +@Provider +class YdbRetryableGenericJdbcExceptionMapper : ExceptionMapper { + @Context + private lateinit var session: KeycloakSession + + override fun toResponse(exception: GenericJDBCException): Response = + mapOrDelegate(session, exception, "GenericJDBCException") +} + +@Provider +class YdbRetryablePersistenceExceptionMapper : ExceptionMapper { + @Context + private lateinit var session: KeycloakSession + + override fun toResponse(exception: PersistenceException): Response = + mapOrDelegate(session, exception, "PersistenceException") +} + +private fun mapOrDelegate(session: KeycloakSession, exception: Throwable, label: String): Response { + if (!isYdbRetryable(exception)) { + return KeycloakErrorHandler.getResponse(session, exception) + } + LOG.warn("YDB retryable $label, returning 503") + return YdbRetryableResponses.build503(exception, YdbRetryableResponses.CONTENTION_DESCRIPTION) +} + +private val LOG: Logger = Logger.getLogger("tech.ydb.keycloak.error.YdbRetryableExceptionMappers") diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt new file mode 100644 index 00000000..8d948f05 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt @@ -0,0 +1,188 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.Liquibase +import liquibase.exception.DatabaseException +import liquibase.exception.LiquibaseException +import org.jboss.logging.Logger +import org.keycloak.common.util.Retry +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProvider +import org.keycloak.connections.jpa.updater.liquibase.lock.LockRetryException +import org.keycloak.models.KeycloakSession +import org.keycloak.models.dblock.DBLockProvider +import org.keycloak.models.utils.KeycloakModelUtils +import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl +import java.sql.Connection +import java.sql.SQLException + +class YdbDBLockProvider( + private val factory: YdbDBLockProviderFactory, + private val session: KeycloakSession +) : DBLockProvider { + private companion object { + private const val DEFAULT_MAX_ATTEMPTS = 10 + private const val INTERVAL_BASE_MILLIS = 10 + } + + private val logger: Logger = Logger.getLogger(YdbDBLockProvider::class.java) + + private var lockService: YdbLockService? = null + private var dbConnection: Connection? = null + private var initialized = false + private var namespaceLocked: DBLockProvider.Namespace? = null + + private fun lazyInit() { + if (!initialized) { + val liquibaseProvider = session.getProvider(LiquibaseConnectionProvider::class.java) + val jpaProviderFactory = session.keycloakSessionFactory + .getProviderFactory(JpaConnectionProvider::class.java) as YdbConnectionProviderFactoryImpl + + this.dbConnection = jpaProviderFactory.connection + val defaultSchema = jpaProviderFactory.schema + + try { + val liquibase: Liquibase = liquibaseProvider.getLiquibase(dbConnection, defaultSchema) + + this.lockService = YdbLockService().apply { + setChangeLogLockWaitTime(factory.lockWaitTimeoutMillis) + setDatabase(liquibase.database) + } + initialized = true + } catch (e: LiquibaseException) { + safeRollbackConnection() + safeCloseConnection() + throw IllegalStateException(e) + } + } + } + + // Assumed transaction was rolled-back and we want to start with new DB connection + private fun restart() { + safeCloseConnection() + lazyInit() + } + + override fun waitForLock(lock: DBLockProvider.Namespace) { + KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) { + lazyInit() + if (checkNotNull(lockService).hasChangeLogLock()) { + if (lock == this.namespaceLocked) { + logger.warn("Locking namespace $lock which was already locked in this provider") + return@suspendJtaTransaction + } else { + throw RuntimeException(String.format("Trying to get a lock when one was already taken by the provider")) + } + } + + logger.debug("Going to lock namespace=$lock") + Retry.executeWithBackoff({ + checkNotNull(lockService).waitForLock(lock) + namespaceLocked = lock + }, { iteration: Int, e: Throwable? -> + if (e is LockRetryException && iteration < (DEFAULT_MAX_ATTEMPTS - 1)) { + // Indicates we should try to acquire lock again in different transaction + safeRollbackConnection() + restart() + } else { + safeRollbackConnection() + safeCloseConnection() + } + }, DEFAULT_MAX_ATTEMPTS, INTERVAL_BASE_MILLIS) + } + } + + override fun releaseLock() { + KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) { + lazyInit() + logger.debug("Going to release database lock namespace=$namespaceLocked") + val (lockId, service) = checkLockBeforeRelease() ?: return@suspendJtaTransaction + try { + Retry.executeWithBackoff({ iteration: Int -> + logger.debug("Release lock attempt ${iteration + 1}") + service.tryReleaseLock(lockId) + }, { iteration: Int, _: Throwable? -> + if (iteration < DEFAULT_MAX_ATTEMPTS - 1) { + safeRollbackConnection() + } else { + safeRollbackConnection() + safeCloseConnection() + } + }, DEFAULT_MAX_ATTEMPTS, INTERVAL_BASE_MILLIS) + } catch (e: RuntimeException) { + logger.error("Failed to release lock after $DEFAULT_MAX_ATTEMPTS attempts", e) + } finally { + namespaceLocked = null + service.cleanupLockState() + service.reset() + } + } + } + + override fun destroyLockInfo() { + KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) { + lazyInit() + try { + checkNotNull(lockService).destroy() + checkNotNull(dbConnection).commit() + logger.debug("Destroyed lock table") + } catch (_: DatabaseException) { + logger.error("Failed to destroy lock table") + safeRollbackConnection() + } catch (_: SQLException) { + logger.error("Failed to destroy lock table") + safeRollbackConnection() + } + } + } + + override fun getCurrentLock(): DBLockProvider.Namespace? = namespaceLocked + + override fun supportsForcedUnlock(): Boolean = false + + private fun safeRollbackConnection() { + try { + dbConnection?.rollback() + } catch (se: SQLException) { + logger.warn("Failed to rollback connection after error", se) + } + } + + private fun safeCloseConnection() { + if (dbConnection != null) { + try { + dbConnection?.close() + dbConnection = null + lockService = null + initialized = false + } catch (e: SQLException) { + logger.warn("Failed to close connection", e) + } + } + } + + override fun close() { + KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) { + safeCloseConnection() + } + } + + private fun checkLockBeforeRelease(): Pair? { + val lockId = namespaceLocked?.id + if (lockId == null) { + logger.debug("releaseLock called but no lock was held by this provider") + return null + } + val service = lockService + if (service == null) { + logger.error("releaseLock called but lockService is null, namespace=$namespaceLocked") + namespaceLocked = null + return null + } + if (!service.hasChangeLogLock()) { + logger.error("releaseLock called but lockService has no lock, namespace=$namespaceLocked") + namespaceLocked = null + return null + } + return Pair(lockId, service) + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt new file mode 100644 index 00000000..6eabaea4 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt @@ -0,0 +1,56 @@ +package tech.ydb.keycloak.liquibase + +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.common.util.Time +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.models.dblock.DBLockProviderFactory +import org.keycloak.provider.ProviderConfigProperty +import org.keycloak.provider.ProviderConfigurationBuilder +import tech.ydb.keycloak.config.ProviderConfig + +class YdbDBLockProviderFactory : DBLockProviderFactory { + private val logger: Logger = Logger.getLogger(YdbDBLockProviderFactory::class.java) + + var lockWaitTimeoutMillis: Long = 0 + private set + + override fun init(config: Config.Scope) { + this.lockWaitTimeoutMillis = Time.toMillis(config.getLong("lockWaitTimeout", 900)) + logger.debug("Liquibase lock provider configured with lockWaitTimeout: ${lockWaitTimeoutMillis}ms") + } + + override fun create(session: KeycloakSession): YdbDBLockProvider { + return YdbDBLockProvider(this, session) + } + + override fun postInit(factory: KeycloakSessionFactory?) { + // no operations + } + + override fun setTimeouts(lockRecheckTimeMillis: Long, lockWaitTimeoutMillis: Long) { + this.lockWaitTimeoutMillis = lockWaitTimeoutMillis + } + + override fun close() { + // no operations + } + + override fun getConfigMetadata(): MutableList? { + return ProviderConfigurationBuilder.create() + .property() + .name("lockWaitTimeout") + .type("int") + .helpText("The maximum time to wait when waiting to release a database lock.") + .add().build() + } + + override fun order(): Int { + return ProviderConfig.PROVIDER_PRIORITY + } + + override fun getId(): String { + return ProviderConfig.PROVIDER_ID + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt new file mode 100644 index 00000000..3a9ae424 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -0,0 +1,77 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.database.AbstractJdbcDatabase +import liquibase.database.Database +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.ClassLoaderResourceAccessor +import liquibase.resource.ResourceAccessor +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.connections.jpa.updater.liquibase.conn.DefaultLiquibaseConnectionProvider +import org.keycloak.connections.jpa.updater.liquibase.conn.KeycloakLiquibase +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY +import tech.ydb.liquibase.database.YdbDatabase +import java.sql.Connection + +class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider() { + + private var indexCreationThreshold: Long = DEFAULT_INDEX_CREATION_THRESHOLD + + override fun init(config: Config.Scope) { + indexCreationThreshold = config.getLong("indexCreationThreshold", DEFAULT_INDEX_CREATION_THRESHOLD) + logger.debugf("indexCreationThreshold is %d", indexCreationThreshold) + } + + override fun getId(): String = PROVIDER_ID + + override fun order(): Int = PROVIDER_PRIORITY + + override fun getLiquibase(connection: Connection, defaultSchema: String?): KeycloakLiquibase { + val database = newYdbDatabase(connection) + if (!defaultSchema.isNullOrBlank()) { + database.defaultSchemaName = defaultSchema + } + val resourceAccessor: ResourceAccessor = ClassLoaderResourceAccessor(javaClass.classLoader) + logger.debugf( + "Using YDB Liquibase changelog %s and changelogTableName %s", + YDB_MASTER_CHANGELOG, + database.databaseChangeLogTableName + ) + (database as AbstractJdbcDatabase).set( + INDEX_CREATION_THRESHOLD_PARAM, + indexCreationThreshold + ) + return KeycloakLiquibase(YDB_MASTER_CHANGELOG, resourceAccessor, database) + } + + override fun getLiquibaseForCustomUpdate( + connection: Connection, + defaultSchema: String?, + changelogLocation: String, + classloader: ClassLoader, + changelogTableName: String + ): KeycloakLiquibase { + val database = newYdbDatabase(connection) + if (!defaultSchema.isNullOrBlank()) { + database.defaultSchemaName = defaultSchema + } + val resourceAccessor = ClassLoaderResourceAccessor(classloader) + database.databaseChangeLogTableName = changelogTableName + + logger.debugf("Using YDB Liquibase for custom update $changelogLocation and changelogTableName ${database.databaseChangeLogTableName}") + + return KeycloakLiquibase(changelogLocation, resourceAccessor, database) + } + + private fun newYdbDatabase(connection: Connection): Database = YdbDatabase().also { + it.connection = JdbcConnection(connection) + } + + companion object { + const val YDB_MASTER_CHANGELOG: String = "ydb/db.changelog-master.xml" + + private const val DEFAULT_INDEX_CREATION_THRESHOLD = 300000L + private val logger = Logger.getLogger(YdbLiquibaseConnectionProvider::class.java) + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt new file mode 100644 index 00000000..80a5a752 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt @@ -0,0 +1,204 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.Scope +import liquibase.exception.DatabaseException +import liquibase.executor.ExecutorService +import liquibase.lockservice.StandardLockService +import liquibase.statement.core.DeleteStatement +import liquibase.statement.core.RawSqlStatement +import liquibase.statement.core.LockDatabaseChangeLogStatement +import org.jboss.logging.Logger +import org.keycloak.common.util.Time +import org.keycloak.common.util.reflections.Reflections +import org.keycloak.connections.jpa.updater.liquibase.LiquibaseConstants +import org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockDatabaseChangeLogStatement +import org.keycloak.connections.jpa.updater.liquibase.lock.LockRetryException +import org.keycloak.models.dblock.DBLockProvider +import tech.ydb.liquibase.lockservice.StandardLockServiceYdb + +class YdbLockService : StandardLockServiceYdb() { + private val log: Logger = Logger.getLogger(YdbLockService::class.java) + + override fun init() { + val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java) + .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database) + + if (!isDatabaseChangeLogLockTableCreated) { + try { + log.trace("Create Database Lock Table") + executor.execute(RawSqlStatement(buildCreateLockTableSql())) + database.commit() + } catch (de: DatabaseException) { + log.warn("Failed to create lock table. Maybe other transaction created in the meantime. Retrying...", de) + log.trace(de.message, de) + database.rollback() + throw LockRetryException(de) + } + + log.debug("Created database lock table") + + try { + val field = Reflections.findDeclaredField(StandardLockService::class.java, "hasDatabaseChangeLogLockTable") + Reflections.setAccessible(field) + field.set(this@YdbLockService, true) + } catch (iae: IllegalAccessException) { + throw RuntimeException(iae) + } + } + + // Clean up any LOCKED=false rows left by manual intervention. + // With INSERT/DELETE semantics, only LOCKED=true rows (actively held locks) should exist. + try { + executor.execute( + DeleteStatement(database.liquibaseCatalogName, database.liquibaseSchemaName, database.databaseChangeLogLockTableName) + .setWhere("LOCKED = false") + ) + database.commit() + } catch (de: DatabaseException) { + database.rollback() + throw LockRetryException(de) + } + } + + override fun waitForLock() { + waitForLock(LockDatabaseChangeLogStatement()) + } + + fun waitForLock(lock: DBLockProvider.Namespace) { + waitForLock(CustomLockDatabaseChangeLogStatement(lock.id)) + } + + private fun waitForLock(lockStmt: LockDatabaseChangeLogStatement) { + var locked = false + val startTime = Time.toMillis(Time.currentTime().toLong()) + val timeToGiveUp = startTime + (getChangeLogLockWaitTime()) + var nextAttempt = true + + while (nextAttempt) { + locked = acquireLock(lockStmt) + if (!locked) { + val remainingTime = ((timeToGiveUp / 1000).toInt()) - Time.currentTime() + if (remainingTime > 0) { + log.debug("Will try to acquire log another time. Remaining time: $remainingTime seconds") + } else { + nextAttempt = false + } + } else { + nextAttempt = false + } + } + + if (!locked) { + val timeout = ((getChangeLogLockWaitTime() / 1000).toInt()) + throw IllegalStateException("Could not acquire change log lock within specified timeout $timeout seconds. Currently locked by other transaction") + } + } + + override fun acquireLock(): Boolean { + return acquireLock(LockDatabaseChangeLogStatement()) + } + + private fun acquireLock(lockStmt: LockDatabaseChangeLogStatement): Boolean { + if (hasChangeLogLock) { + return true + } + + val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java) + .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database) + + try { + database.rollback() + this.init() + } catch (de: DatabaseException) { + throw IllegalStateException("Failed to retrieve lock", de) + } + + val id = if (lockStmt is CustomLockDatabaseChangeLogStatement) lockStmt.id else DEFAULT_LOCK_ID + + return try { + log.debug("Trying to acquire lock id=$id") + executor.execute(YdbLockStatement(id)) + database.commit() + + hasChangeLogLock = true + database.setCanCacheLiquibaseTableInfo(true) + log.debug("Successfully acquired lock id=$id") + true + } catch (de: DatabaseException) { + log.debug("Lock id=$id is held by another transaction, will retry. Details: ${de.message}") + try { + database.rollback() + } catch (_: DatabaseException) { + // no operations + } + false + } + } + + + fun tryReleaseLock(lockId: Int) { + log.debug("Going to release database lock id=$lockId") + val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java) + .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database) + + database.rollback() + try { + executor.execute(YdbUnlockStatement(lockId)) + database.commit() + } catch (e: Exception) { + throw RuntimeException("Failed to release lock id=$lockId", e) + } + } + + fun cleanupLockState() { + try { + hasChangeLogLock = false + database.setCanCacheLiquibaseTableInfo(false) + database.rollback() + } catch (_: DatabaseException) { + // no operations + } + } + + override fun releaseLock() { + releaseLock(DEFAULT_LOCK_ID) + } + + fun releaseLock(lockId: Int) { + try { + if (hasChangeLogLock) { + tryReleaseLock(lockId) + } else { + log.warn("Attempt to release lock, which is not owned by current transaction") + } + } catch (e: Exception) { + log.error("Database error during release lock", e) + } finally { + cleanupLockState() + } + } + + /** + * YQL for Liquibase lock table (see [tech.ydb.liquibase.sqlgenerator.CreateDatabaseChangeLogLockTableGeneratorYdb]). + * Raw SQL is used here because generic Liquibase emits PostgreSQL DDL (INT, BOOLEAN, VARCHAR), + * which YDB rejects. + */ + private fun buildCreateLockTableSql(): String { + val tableName = database.escapeTableName( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName + ) + return "CREATE TABLE $tableName (" + + "ID INT32, " + + "LOCKED BOOL, " + + "LOCKGRANTED DATETIME, " + + "LOCKEDBY TEXT, " + + "PRIMARY KEY(ID)" + + ")" + } + + companion object { + private val DEFAULT_LOCK_ID = DBLockProvider.Namespace.DATABASE.id + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt new file mode 100644 index 00000000..d9faeb50 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt @@ -0,0 +1,61 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.database.Database +import liquibase.exception.ValidationErrors +import liquibase.sql.Sql +import liquibase.sqlgenerator.SqlGeneratorChain +import liquibase.sqlgenerator.SqlGeneratorFactory +import liquibase.sqlgenerator.core.LockDatabaseChangeLogGenerator +import liquibase.statement.DatabaseFunction +import liquibase.statement.core.InsertStatement +import liquibase.statement.core.LockDatabaseChangeLogStatement +import tech.ydb.liquibase.database.YdbDatabase + +/** + * Generates an INSERT-based lock acquisition for YDB. + * + * YDB does not support SELECT FOR UPDATE. Instead, we use INSERT: inserting + * a row succeeds only when no row with that ID exists (lock is free). If the row + * already exists (lock is held), the INSERT fails with a primary-key violation, + * which the caller catches and interprets as "lock busy, retry later". + * + * Lock table invariant: absence of a row means "unlocked"; presence means "locked". + * The table is kept empty during init — see YdbLockService.init(). + * Release is done by DELETE — see YdbUnlockSqlGenerator. + * + * Extends LockDatabaseChangeLogGenerator to inherit hostname/hostaddress/hostDescription + * static fields + */ +class YdbLockSqlGenerator : LockDatabaseChangeLogGenerator() { + + override fun getPriority(): Int = PRIORITY_DATABASE + + override fun supports(statement: LockDatabaseChangeLogStatement, database: Database): Boolean = + statement is YdbLockStatement && database is YdbDatabase + + override fun validate( + statement: LockDatabaseChangeLogStatement, + database: Database, + chain: SqlGeneratorChain<*> + ): ValidationErrors = ValidationErrors() + + override fun generateSql( + statement: LockDatabaseChangeLogStatement, + database: Database, + chain: SqlGeneratorChain<*> + ): Array { + statement as YdbLockStatement + + val insertStatement = InsertStatement( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName + ) + .addColumnValue("ID", statement.id) + .addColumnValue("LOCKED", true) + .addColumnValue("LOCKGRANTED", DatabaseFunction("CurrentUtcDatetime()")) + .addColumnValue("LOCKEDBY", "$hostname$hostDescription ($hostaddress)") + + return SqlGeneratorFactory.getInstance().generateSql(insertStatement, database) + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt new file mode 100644 index 00000000..a04c2cea --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt @@ -0,0 +1,5 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.statement.core.LockDatabaseChangeLogStatement + +class YdbLockStatement(val id: Int) : LockDatabaseChangeLogStatement() diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt new file mode 100644 index 00000000..5113620e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt @@ -0,0 +1,44 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.database.Database +import liquibase.exception.ValidationErrors +import liquibase.sql.Sql +import liquibase.sqlgenerator.SqlGeneratorChain +import liquibase.sqlgenerator.SqlGeneratorFactory +import liquibase.sqlgenerator.core.AbstractSqlGenerator +import liquibase.statement.core.DeleteStatement +import tech.ydb.liquibase.database.YdbDatabase + +/** + * Generates a DELETE-based lock release for YDB. + * + * The lock table invariant is: presence of a row = locked, absence = unlocked. + * Deleting the row releases the lock and makes it available for the next process. + */ +class YdbUnlockSqlGenerator : AbstractSqlGenerator() { + + override fun getPriority(): Int = PRIORITY_DATABASE + + override fun supports(statement: YdbUnlockStatement, database: Database): Boolean = + database is YdbDatabase + + override fun validate( + statement: YdbUnlockStatement, + database: Database, + chain: SqlGeneratorChain + ): ValidationErrors = ValidationErrors() + + override fun generateSql( + statement: YdbUnlockStatement, + database: Database, + chain: SqlGeneratorChain + ): Array { + val deleteStatement = DeleteStatement( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName + ).setWhere("ID = ${statement.id}") + + return SqlGeneratorFactory.getInstance().generateSql(deleteStatement, database) + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt new file mode 100644 index 00000000..0d6c1c50 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt @@ -0,0 +1,5 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.statement.core.UnlockDatabaseChangeLogStatement + +class YdbUnlockStatement(val id: Int) : UnlockDatabaseChangeLogStatement() diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt new file mode 100644 index 00000000..c5d8ec2c --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -0,0 +1,143 @@ +package tech.ydb.keycloak.realm + +import jakarta.persistence.EntityManager +import org.keycloak.common.util.StackUtil +import org.keycloak.models.ClientModel +import org.keycloak.models.ClientModel.ClientRemovedEvent +import org.keycloak.models.ClientScopeModel +import org.keycloak.models.ClientScopeModel.ClientScopeRemovedEvent +import org.keycloak.models.KeycloakSession +import org.keycloak.models.RealmModel +import org.keycloak.models.jpa.JpaRealmProvider +import org.keycloak.models.jpa.RealmAdapter +import org.keycloak.models.jpa.entities.ClientEntity +import org.keycloak.models.jpa.entities.ClientScopeEntity +import org.keycloak.models.jpa.entities.RealmEntity + + +/** + * Realm provider for YDB. Overrides some functions to load the realm entity without + * PESSIMISTIC_WRITE lock, because YDB does not support FOR UPDATE. + */ +class YdbRealmProvider( + private val keycloakSession: KeycloakSession, + entityManager: EntityManager, + clientSearchableAttributes: Set? = null, + groupSearchableAttributes: Set? = null, +) : JpaRealmProvider( + keycloakSession, + entityManager, + clientSearchableAttributes, + groupSearchableAttributes, +) { + + override fun removeRealm(id: String): Boolean { + // YDB does not support FOR UPDATE (PESSIMISTIC_WRITE) + val realm = em.find(RealmEntity::class.java, id) ?: return false + val adapter = RealmAdapter(keycloakSession, em, realm) + keycloakSession.users().preRemove(adapter) + + realm.defaultGroupIds.clear() + em.flush() + + keycloakSession.clients().removeClients(adapter) + em.createNamedQuery("deleteDefaultClientScopeRealmMappingByRealm") + .setParameter("realm", realm).executeUpdate() + + keycloakSession.clientScopes().removeClientScopes(adapter) + keycloakSession.roles().removeRoles(adapter) + + em.createNamedQuery("deleteOrganizationDomainsByRealm") + .setParameter("realmId", realm.id).executeUpdate() + em.createNamedQuery("deleteOrganizationsByRealm") + .setParameter("realmId", realm.id).executeUpdate() + keycloakSession.groups().preRemove(adapter) + + keycloakSession.identityProviders().removeAll() + keycloakSession.identityProviders().removeAllMappers() + + em.createNamedQuery("removeClientInitialAccessByRealm") + .setParameter("realm", realm).executeUpdate() + + em.remove(realm) + em.flush() + em.clear() + + val session = keycloakSession + keycloakSession.keycloakSessionFactory.publish(object : RealmModel.RealmRemovedEvent { + override fun getRealm(): RealmModel = adapter + override fun getKeycloakSession(): KeycloakSession = session + }) + return true + } + + override fun removeClient(realm: RealmModel, id: String?): Boolean { + logger.tracef("removeClient(%s, %s)%s", realm, id, StackUtil.getShortStackTrace()) + + val client = getClientById(realm, id) ?: return false + + keycloakSession.users().preRemove(realm, client) + keycloakSession.roles().removeRoles(client) + + // YDB does not support FOR UPDATE (PESSIMISTIC_WRITE) + val clientEntity = em.find(ClientEntity::class.java, id) + + keycloakSession.keycloakSessionFactory.publish(object : ClientRemovedEvent { + override fun getClient(): ClientModel { + return client + } + + override fun getKeycloakSession(): KeycloakSession { + // without `this@YdbRealmProvider` here will be infinite recursion + return this@YdbRealmProvider.keycloakSession + } + }) + + val countRemoved = em.createNamedQuery("deleteClientScopeClientMappingByClient") + .setParameter("clientId", clientEntity.id) + .executeUpdate() + + // !!! comment from JpaRealmProvider))) + // i have no idea why, but this needs to come before deleteScopeMapping + em.remove(clientEntity) + + try { + em.flush() + } catch (e: RuntimeException) { + logger.errorv("Unable to delete client entity: {0} from realm {1}", client.clientId, realm.name) + throw e + } + + return true + } + + override fun removeClientScope(realm: RealmModel, id: String?): Boolean { + if (id == null) return false + val clientScope = getClientScopeById(realm, id) + if (clientScope == null) return false + + keycloakSession.users().preRemove(clientScope) + realm.removeDefaultClientScope(clientScope) + // YDB does not support FOR UPDATE (PESSIMISTIC_WRITE) + val clientScopeEntity = em.find(ClientScopeEntity::class.java, id) + + em.createNamedQuery("deleteClientScopeClientMappingByClientScope") + .setParameter("clientScopeId", clientScope.id).executeUpdate() + em.createNamedQuery("deleteClientScopeRoleMappingByClientScope").setParameter("clientScope", clientScopeEntity) + .executeUpdate() + em.remove(clientScopeEntity) + + keycloakSession.keycloakSessionFactory.publish(object : ClientScopeRemovedEvent { + override fun getKeycloakSession(): KeycloakSession { + return this@YdbRealmProvider.keycloakSession + } + + override fun getClientScope(): ClientScopeModel { + return clientScope + } + }) + + em.flush() + return true + } +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt new file mode 100644 index 00000000..1e7fe163 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -0,0 +1,41 @@ +package tech.ydb.keycloak.realm + +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.models.RealmProviderFactory +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY + +class YdbRealmProviderFactory() : RealmProviderFactory { + + private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) + + override fun create(session: KeycloakSession): YdbRealmProvider { + val provider = session.getProvider(JpaConnectionProvider::class.java)?.let { + YdbRealmProvider(session, it.entityManager) + } ?: error("JpaConnectionProvider is not configured in YDB") + + logger.info("YdbRealmProvider successfully created") + + return provider + } + + override fun init(scope: Config.Scope) { + // no operations + } + + override fun postInit(p0: KeycloakSessionFactory?) { + // no operations + } + + override fun close() { + // no operations + } + + override fun getId(): String = PROVIDER_ID + + override fun order(): Int = PROVIDER_PRIORITY +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt new file mode 100644 index 00000000..8e003912 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt @@ -0,0 +1,12 @@ +package tech.ydb.keycloak.utils + +import tech.ydb.jdbc.exception.YdbRetryableException + +fun isYdbRetryable(t: Throwable): Boolean { + var cause: Throwable? = t + while (cause != null) { + if (cause is YdbRetryableException) return true + cause = cause.cause + } + return false +} diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt new file mode 100644 index 00000000..ab9ad8bb --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt @@ -0,0 +1,24 @@ +package tech.ydb.keycloak.utils + +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response + +object YdbRetryableResponses { + const val ERROR_CODE = "ydb_retryable" + + const val CONTENTION_DESCRIPTION = "Transaction aborted due to contention, please retry" + const val TRANSACTION_DESCRIPTION = "Transaction aborted, please retry" + + fun build503(cause: Throwable, errorDescription: String): Response = + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"$ERROR_CODE","error_description":"$errorDescription"}""") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + + fun toWebApplicationException( + cause: Throwable, + message: String = cause.message ?: ERROR_CODE, + errorDescription: String = CONTENTION_DESCRIPTION, + ): WebApplicationException = WebApplicationException(message, cause, build503(cause, errorDescription)) +} diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties b/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties new file mode 100644 index 00000000..56b7dc08 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties @@ -0,0 +1,38 @@ +# YDB-specific named query overrides. +# +# 1. YDB requires each JOIN ON predicate to reference columns from both sides of the join. +# These queries move filter conditions (column = :param) from ON to WHERE. +# 2. YDB does not support implicit cross joins (comma-separated FROM). +# These queries replace "FROM A, B WHERE b = a.field" with explicit JOINs. + +findUserSessionsByClientId[jpql]=SELECT sess FROM PersistentUserSessionEntity sess \ + INNER JOIN PersistentClientSessionEntity clientSess \ + ON sess.userSessionId = clientSess.userSessionId AND sess.offline = clientSess.offline \ + WHERE clientSess.clientId = :clientId \ + AND sess.offline = :offline \ + AND sess.realmId = :realmId \ + AND sess.lastSessionRefresh >= :lastSessionRefresh \ + ORDER BY sess.userSessionId + +findUserSessionsByExternalClientId[jpql]=SELECT sess FROM PersistentUserSessionEntity sess \ + INNER JOIN PersistentClientSessionEntity clientSess \ + ON sess.userSessionId = clientSess.userSessionId AND sess.offline = clientSess.offline \ + WHERE clientSess.clientStorageProvider = :clientStorageProvider \ + AND clientSess.externalClientId = :externalClientId \ + AND sess.offline = :offline \ + AND sess.realmId = :realmId \ + AND sess.lastSessionRefresh >= :lastSessionRefresh \ + ORDER BY sess.userSessionId + +# Original: select sess from PersistentUserSessionEntity sess, RealmEntity realm where realm.id = sess.realmId ... +# The RealmEntity join is a no-op filter; YDB rejects implicit cross join. +findUserSessionsOrderedById[jpql]=SELECT sess FROM PersistentUserSessionEntity sess \ + WHERE sess.offline = :offline \ + AND sess.userSessionId > :lastSessionId \ + ORDER BY sess.userSessionId + +# Original: select u from UserRoleMappingEntity m, UserEntity u where m.roleId=:roleId and u=m.user order by u.username +# YDB does not support implicit cross join (comma-separated FROM). +usersInRole[jpql]=SELECT m.user FROM UserRoleMappingEntity m \ + WHERE m.roleId = :roleId \ + ORDER BY m.user.username \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator new file mode 100644 index 00000000..c52883eb --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator @@ -0,0 +1,2 @@ +tech.ydb.keycloak.liquibase.YdbLockSqlGenerator +tech.ydb.keycloak.liquibase.YdbUnlockSqlGenerator \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory new file mode 100644 index 00000000..9348d601 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory new file mode 100644 index 00000000..9b4b14c7 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.liquibase.YdbLiquibaseConnectionProvider diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory new file mode 100644 index 00000000..07dfd04f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.client.YdbClientProviderFactory \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory new file mode 100644 index 00000000..ec4888dd --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.client.YdbClientScopeProviderFactory \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory new file mode 100644 index 00000000..fb8ab02d --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.realm.YdbRealmProviderFactory \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory new file mode 100644 index 00000000..37336fe8 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.liquibase.YdbDBLockProviderFactory \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql new file mode 100644 index 00000000..2d04eeb8 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql @@ -0,0 +1,60 @@ +CREATE TABLE IF NOT EXISTS REALM +( + `ID` Utf8 NOT NULL, + `ACCESS_CODE_LIFESPAN` Int32, + `USER_ACTION_LIFESPAN` Int32, + `ACCESS_TOKEN_LIFESPAN` Int32, + `ACCOUNT_THEME` Utf8, + `ADMIN_THEME` Utf8, + `EMAIL_THEME` Utf8, + `ENABLED` Bool NOT NULL DEFAULT false, + `EVENTS_ENABLED` Bool NOT NULL DEFAULT false, + `EVENTS_EXPIRATION` Int64, + `LOGIN_THEME` Utf8, + `NAME` Utf8, + `NOT_BEFORE` Int32, + `PASSWORD_POLICY` Utf8, + `REGISTRATION_ALLOWED` Bool NOT NULL DEFAULT false, + `REMEMBER_ME` Bool NOT NULL DEFAULT false, + `RESET_PASSWORD_ALLOWED` Bool NOT NULL DEFAULT false, + `SOCIAL` Bool NOT NULL DEFAULT false, + `SSL_REQUIRED` Utf8, + `SSO_IDLE_TIMEOUT` Int32, + `SSO_MAX_LIFESPAN` Int32, + `UPDATE_PROFILE_ON_SOC_LOGIN` Bool NOT NULL DEFAULT false, + `VERIFY_EMAIL` Bool NOT NULL DEFAULT false, + `MASTER_ADMIN_CLIENT` Utf8, + `LOGIN_LIFESPAN` Int32, + `INTERNATIONALIZATION_ENABLED` Bool NOT NULL DEFAULT false, + `DEFAULT_LOCALE` Utf8, + `REG_EMAIL_AS_USERNAME` Bool NOT NULL DEFAULT false, + `ADMIN_EVENTS_ENABLED` Bool NOT NULL DEFAULT false, + `ADMIN_EVENTS_DETAILS_ENABLED` Bool NOT NULL DEFAULT false, + `EDIT_USERNAME_ALLOWED` Bool NOT NULL DEFAULT false, + `OTP_POLICY_COUNTER` Int32 DEFAULT 0, + `OTP_POLICY_WINDOW` Int32 DEFAULT 1, + `OTP_POLICY_PERIOD` Int32 DEFAULT 30, + `OTP_POLICY_DIGITS` Int32 DEFAULT 6, + `OTP_POLICY_ALG` Utf8 DEFAULT 'HmacSHA1', + `OTP_POLICY_TYPE` Utf8 DEFAULT 'totp', + `BROWSER_FLOW` Utf8, + `REGISTRATION_FLOW` Utf8, + `DIRECT_GRANT_FLOW` Utf8, + `RESET_CREDENTIALS_FLOW` Utf8, + `CLIENT_AUTH_FLOW` Utf8, + `OFFLINE_SESSION_IDLE_TIMEOUT` Int32 NOT NULL DEFAULT 0, + `REVOKE_REFRESH_TOKEN` Bool NOT NULL DEFAULT false, + `ACCESS_TOKEN_LIFE_IMPLICIT` Int32 NOT NULL DEFAULT 0, + `LOGIN_WITH_EMAIL_ALLOWED` Bool NOT NULL DEFAULT true, + `DUPLICATE_EMAILS_ALLOWED` Bool NOT NULL DEFAULT false, + `DOCKER_AUTH_FLOW` Utf8, + `REFRESH_TOKEN_MAX_REUSE` Int32 NOT NULL DEFAULT 0, + `ALLOW_USER_MANAGED_ACCESS` Bool NOT NULL DEFAULT false, + `SSO_MAX_LIFESPAN_REMEMBER_ME` Int32 NOT NULL DEFAULT 0, + `SSO_IDLE_TIMEOUT_REMEMBER_ME` Int32 NOT NULL DEFAULT 0, + `DEFAULT_ROLE` Utf8, + + INDEX realm_name_unique GLOBAL UNIQUE ON (NAME), + INDEX idx_realm_master_adm_cli GLOBAL ON (MASTER_ADMIN_CLIENT), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql new file mode 100644 index 00000000..ac933656 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS REALM_ATTRIBUTE +( + NAME Utf8 NOT NULL, + REALM_ID Utf8 NOT NULL, + VALUE Utf8, + + INDEX realm_attributes_idx_realm_id GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (NAME, REALM_ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql new file mode 100644 index 00000000..efea8a69 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql @@ -0,0 +1,11 @@ +create table IF NOT EXISTS REALM_REQUIRED_CREDENTIAL +( + REALM_ID Utf8 not null, + TYPE Utf8 not null, + FORM_LABEL Utf8, + INPUT Bool not null default false, + SECRET Bool not null default false, + +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (REALM_ID, TYPE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql new file mode 100644 index 00000000..3880b73e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS REALM_EVENTS_LISTENERS +( + `REALM_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_realm_evt_list_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql new file mode 100644 index 00000000..e4010123 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `REALM_DEFAULT_GROUPS` +( + `REALM_ID` Utf8 NOT NULL, + `GROUP_ID` Utf8 NOT NULL, + + INDEX idx_realm_def_grp_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`REALM_ID`, `GROUP_ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql new file mode 100644 index 00000000..de7385ec --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `REALM_ENABLED_EVENT_TYPES` +( + `REALM_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_realm_evt_types_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql new file mode 100644 index 00000000..3fb00d41 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `REALM_LOCALIZATIONS` +( + `REALM_ID` Utf8 NOT NULL, + `LOCALE` Utf8 NOT NULL, + `TEXTS` Text NOT NULL, + + PRIMARY KEY (`REALM_ID`, `LOCALE`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql new file mode 100644 index 00000000..9da6db8b --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `REALM_SMTP_CONFIG` +( + `REALM_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`REALM_ID`, `NAME`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql new file mode 100644 index 00000000..899fdbf9 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `REALM_SUPPORTED_LOCALES` +( + `REALM_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_realm_supp_local_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql new file mode 100644 index 00000000..16fc19de --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `DEFAULT_CLIENT_SCOPE` +( + `REALM_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + `DEFAULT_SCOPE` Bool NOT NULL DEFAULT false, + + INDEX idx_defcls_realm GLOBAL ON (`REALM_ID`), + INDEX idx_defcls_scope GLOBAL ON (`SCOPE_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`REALM_ID`, `SCOPE_ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql new file mode 100644 index 00000000..b788e5ea --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS `AUTHENTICATION_FLOW` +( + `ID` Utf8 NOT NULL, + `ALIAS` Utf8, + `DESCRIPTION` Utf8, + `REALM_ID` Utf8, + `PROVIDER_ID` Utf8 NOT NULL DEFAULT "basic-flow", + `TOP_LEVEL` Bool NOT NULL DEFAULT false, + `BUILT_IN` Bool NOT NULL DEFAULT false, + + INDEX idx_auth_flow_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql new file mode 100644 index 00000000..56ef1b79 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS `AUTHENTICATION_EXECUTION` +( + `ID` Utf8 NOT NULL, + `ALIAS` Utf8, + `AUTHENTICATOR` Utf8, + `REALM_ID` Utf8, + `FLOW_ID` Utf8, + `REQUIREMENT` Int32, + `PRIORITY` Int32, + `AUTHENTICATOR_FLOW` Bool NOT NULL DEFAULT false, + `AUTH_FLOW_ID` Utf8, + `AUTH_CONFIG` Utf8, + + INDEX idx_auth_exec_realm_flow GLOBAL ON (`REALM_ID`, `FLOW_ID`), + INDEX idx_auth_exec_flow GLOBAL ON (`FLOW_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (FLOW_ID) REFERENCES AUTHENTICATION_FLOW (ID), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql new file mode 100644 index 00000000..acfe4879 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `AUTHENTICATOR_CONFIG` +( + `ID` Utf8 NOT NULL, + `ALIAS` Utf8, + `REALM_ID` Utf8, + + INDEX idx_auth_config_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql new file mode 100644 index 00000000..cf5fa858 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `REQUIRED_ACTION_PROVIDER` +( + `ID` Utf8 NOT NULL, + `ALIAS` Utf8, + `NAME` Utf8, + `REALM_ID` Utf8, + `ENABLED` Bool NOT NULL DEFAULT false, + `DEFAULT_ACTION` Bool NOT NULL DEFAULT false, + `PROVIDER_ID` Utf8, + `PRIORITY` Int32, + + INDEX idx_req_act_prov_realm GLOBAL ON (`REALM_ID`), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql new file mode 100644 index 00000000..5cfcf531 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `REQUIRED_ACTION_CONFIG` +( + `REQUIRED_ACTION_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + PRIMARY KEY (`REQUIRED_ACTION_ID`, `NAME`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql new file mode 100644 index 00000000..53c25a4f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `AUTHENTICATOR_CONFIG_ENTRY` +( + `AUTHENTICATOR_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + PRIMARY KEY (`AUTHENTICATOR_ID`, `NAME`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql new file mode 100644 index 00000000..8bf697ca --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS COMPONENT +( + `ID` Utf8 NOT NULL, + `NAME` Utf8, + `PARENT_ID` Utf8, + `PROVIDER_ID` Utf8, + `PROVIDER_TYPE` Utf8, + `REALM_ID` Utf8, + `SUB_TYPE` Utf8, + + INDEX idx_component_realm GLOBAL ON (REALM_ID), + INDEX idx_component_provider_type GLOBAL ON (PROVIDER_TYPE), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql new file mode 100644 index 00000000..a391c65d --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS COMPONENT_CONFIG +( + `ID` Utf8 NOT NULL, + `COMPONENT_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + + INDEX idx_compo_config_compo GLOBAL ON (COMPONENT_ID), +-- FOREIGN KEY (COMPONENT_ID) REFERENCES COMPONENT (ID), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql new file mode 100644 index 00000000..b2f7cb03 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS CLIENT +( + `ID` Utf8 NOT NULL, + `ENABLED` Bool DEFAULT false, + `FULL_SCOPE_ALLOWED` Bool DEFAULT false, + `CLIENT_ID` Utf8, + `NOT_BEFORE` Int32, + `PUBLIC_CLIENT` Bool DEFAULT false, + `SECRET` Utf8, + `BASE_URL` Utf8, + `BEARER_ONLY` Bool DEFAULT false, + `MANAGEMENT_URL` Utf8, + `SURROGATE_AUTH_REQUIRED` Bool DEFAULT false, + `REALM_ID` Utf8, + `PROTOCOL` Utf8, + `NODE_REREG_TIMEOUT` Int32 DEFAULT 0, + `FRONTCHANNEL_LOGOUT` Bool DEFAULT false, + `CONSENT_REQUIRED` Bool DEFAULT false, + `NAME` Utf8, + `SERVICE_ACCOUNTS_ENABLED` Bool DEFAULT false, + `CLIENT_AUTHENTICATOR_TYPE` Utf8, + `ROOT_URL` Utf8, + `DESCRIPTION` Utf8, + `REGISTRATION_TOKEN` Utf8, + `STANDARD_FLOW_ENABLED` Bool DEFAULT true, + `IMPLICIT_FLOW_ENABLED` Bool DEFAULT false, + `DIRECT_ACCESS_GRANTS_ENABLED` Bool DEFAULT false, + `ALWAYS_DISPLAY_IN_CONSOLE` Bool DEFAULT false, + + INDEX idx_client_id GLOBAL ASYNC ON (`CLIENT_ID`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql new file mode 100644 index 00000000..67837bac --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS EVENT_ENTITY +( + `ID` Utf8 NOT NULL, + `CLIENT_ID` Utf8, + `DETAILS_JSON` Utf8, + `ERROR` Utf8, + `IP_ADDRESS` Utf8, + `REALM_ID` Utf8, + `SESSION_ID` Utf8, + `EVENT_TIME` Int64, + `TYPE` Utf8, + `USER_ID` Utf8, + `DETAILS_JSON_LONG_VALUE` Text, + + INDEX idx_event_time GLOBAL ON (`REALM_ID`, `EVENT_TIME`), + INDEX idx_event_entity_user_id_type GLOBAL ON (`USER_ID`, `TYPE`, `EVENT_TIME`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql new file mode 100644 index 00000000..fd757fa7 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS KEYCLOAK_ROLE +( + `ID` Utf8 NOT NULL, + `CLIENT_REALM_CONSTRAINT` Utf8, + `CLIENT_ROLE` Bool NOT NULL DEFAULT false, + `DESCRIPTION` Utf8, + `NAME` Utf8, + `REALM_ID` Utf8, + `CLIENT` Utf8, + `REALM` Utf8, + + INDEX idx_keycloak_role_client GLOBAL ON (`CLIENT`), + + INDEX fk_6vyqfe4cn4wlq8r6kt5vdsj5c GLOBAL ON (`REALM`), + INDEX `UK_J3RWUVD56ONTGSUHOGM184WW2-2` GLOBAL UNIQUE ON (`NAME`, `CLIENT_REALM_CONSTRAINT`), +-- FOREIGN KEY (REALM) REFERENCES REALM (ID), + PRIMARY KEY (`ID`) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql new file mode 100644 index 00000000..24294957 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS COMPOSITE_ROLE +( + `COMPOSITE` Utf8 NOT NULL, + `CHILD_ROLE` Utf8 NOT NULL, + + INDEX idx_composite GLOBAL ON (COMPOSITE), + INDEX idx_composite_child GLOBAL ON (CHILD_ROLE), +-- FOREIGN KEY (COMPOSITE) REFERENCES KEYCLOAK_ROLE (ID), +-- FOREIGN KEY (CHILD_ROLE) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (COMPOSITE, CHILD_ROLE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql new file mode 100644 index 00000000..58d7af16 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS ROLE_ATTRIBUTE +( + `ID` Utf8 NOT NULL, + `ROLE_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + + INDEX idx_role_attribute GLOBAL ON (ROLE_ID), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql new file mode 100644 index 00000000..9a805853 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS MIGRATION_MODEL +( + `ID` Utf8 NOT NULL, + `VERSION` Utf8, + `UPDATE_TIME` Int64 NOT NULL DEFAULT 0, + + INDEX uk_migration_version GLOBAL UNIQUE ON (`VERSION`), + INDEX uk_migration_update_time GLOBAL UNIQUE ON (`UPDATE_TIME`), + INDEX idx_update_time GLOBAL ON (`UPDATE_TIME`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql new file mode 100644 index 00000000..db936ced --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS CLIENT_ATTRIBUTES +( + `CLIENT_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + + INDEX idx_client_att_by_name_value GLOBAL ON (`NAME`, `VALUE`), +-- INDEX idx_client_att_by_name_value GLOBAL ON (`NAME`, SUBSTRING(`VALUE`, 1, 255)), +-- not implemented in ydb... +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, NAME) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql new file mode 100644 index 00000000..f4ab0d82 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS REDIRECT_URIS +( + `CLIENT_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_redir_uri_client GLOBAL ON (CLIENT_ID), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, VALUE) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql new file mode 100644 index 00000000..28c28301 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS PROTOCOL_MAPPER +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `PROTOCOL` Utf8 NOT NULL, + `PROTOCOL_MAPPER_NAME` Utf8 NOT NULL, + `CLIENT_ID` Utf8, + `CLIENT_SCOPE_ID` Utf8, + + INDEX idx_protocol_mapper_client GLOBAL ON (CLIENT_ID), + INDEX idx_clscope_protmap GLOBAL ON (CLIENT_SCOPE_ID), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), +-- FOREIGN KEY (CLIENT_SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql new file mode 100644 index 00000000..fdf8a131 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS PROTOCOL_MAPPER_CONFIG +( + `PROTOCOL_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (PROTOCOL_MAPPER_ID) REFERENCES PROTOCOL_MAPPER (ID), + PRIMARY KEY (PROTOCOL_MAPPER_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql new file mode 100644 index 00000000..2f454fb4 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS SCOPE_MAPPING +( + `CLIENT_ID` Utf8 NOT NULL, + `ROLE_ID` Utf8 NOT NULL, + + INDEX idx_scope_mapping_role GLOBAL ON (ROLE_ID), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, ROLE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql new file mode 100644 index 00000000..5f0ca7b9 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS WEB_ORIGINS +( + `CLIENT_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_web_orig_client GLOBAL ON (CLIENT_ID), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, VALUE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql new file mode 100644 index 00000000..8046c894 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS CLIENT_SCOPE +( + `ID` Utf8 NOT NULL, + `NAME` Utf8, + `REALM_ID` Utf8, + `DESCRIPTION` Utf8, + `PROTOCOL` Utf8, + +-- in pg +-- INDEX idx_realm_clscope GLOBAL ON (REALM_ID), +-- CONSTRAINT `UK_CLI_SCOPE` GLOBAL UNIQUE ON (REALM_ID, NAME), + INDEX idx_realm_clscope GLOBAL UNIQUE ON (REALM_ID, NAME), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql new file mode 100644 index 00000000..d0aa3e57 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_ATTRIBUTES +( + `SCOPE_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_clscope_attrs GLOBAL ON (SCOPE_ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (SCOPE_ID, NAME) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql new file mode 100644 index 00000000..05c8307a --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_CLIENT +( + `CLIENT_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + `DEFAULT_SCOPE` Bool NOT NULL DEFAULT false, + + INDEX idx_clscope_cl GLOBAL ON (CLIENT_ID), + INDEX idx_cl_clscope GLOBAL ON (SCOPE_ID), + PRIMARY KEY (CLIENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql new file mode 100644 index 00000000..fabf479a --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_ROLE_MAPPING +( + `SCOPE_ID` Utf8 NOT NULL, + `ROLE_ID` Utf8 NOT NULL, + + INDEX idx_clscope_role GLOBAL ON (SCOPE_ID), + INDEX idx_role_clscope GLOBAL ON (ROLE_ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (SCOPE_ID, ROLE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql new file mode 100644 index 00000000..e210c270 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS USER_ENTITY +( + `ID` Utf8 NOT NULL, + `EMAIL` Utf8, + `EMAIL_CONSTRAINT` Utf8, + `EMAIL_VERIFIED` Bool NOT NULL DEFAULT false, + `ENABLED` Bool NOT NULL DEFAULT false, + `FEDERATION_LINK` Utf8, + `FIRST_NAME` Utf8, + `LAST_NAME` Utf8, + `REALM_ID` Utf8, + `USERNAME` Utf8, + `CREATED_TIMESTAMP` Int64, + `SERVICE_ACCOUNT_CLIENT_LINK` Utf8, + `NOT_BEFORE` Int32 NOT NULL DEFAULT 0, + + INDEX idx_user_email GLOBAL ON (EMAIL), + INDEX idx_user_service_account GLOBAL ASYNC ON (REALM_ID, SERVICE_ACCOUNT_CLIENT_LINK), +-- CONSTRAINT `UK_DYKN684SL8UP1CRFEI6ECKHD7` GLOBAL UNIQUE ON (REALM_ID, EMAIL_CONSTRAINT), +-- CONSTRAINT `UK_RU8TT6T700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (REALM_ID, USERNAME), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql new file mode 100644 index 00000000..65485a75 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS USER_ROLE_MAPPING +( + `ROLE_ID` Utf8 NOT NULL, + `USER_ID` Utf8 NOT NULL, + + INDEX idx_user_role_mapping GLOBAL ON (USER_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (ROLE_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql new file mode 100644 index 00000000..92e27bc2 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER +( + `INTERNAL_ID` Utf8 NOT NULL, + `ENABLED` Bool NOT NULL DEFAULT false, + `PROVIDER_ALIAS` Utf8, + `PROVIDER_ID` Utf8, + `STORE_TOKEN` Bool NOT NULL DEFAULT false, + `AUTHENTICATE_BY_DEFAULT` Bool NOT NULL DEFAULT false, + `REALM_ID` Utf8, + `ADD_TOKEN_ROLE` Bool NOT NULL DEFAULT true, + `TRUST_EMAIL` Bool NOT NULL DEFAULT false, + `FIRST_BROKER_LOGIN_FLOW_ID` Utf8, + `POST_BROKER_LOGIN_FLOW_ID` Utf8, + `PROVIDER_DISPLAY_NAME` Utf8, + `LINK_ONLY` Bool NOT NULL DEFAULT false, + `ORGANIZATION_ID` Utf8, + `HIDE_ON_LOGIN` Bool DEFAULT false, + + INDEX idx_ident_prov_realm GLOBAL ON (REALM_ID), + INDEX idx_idp_realm_org GLOBAL ON (REALM_ID, ORGANIZATION_ID), + INDEX idx_idp_for_login GLOBAL ON (REALM_ID, ENABLED, LINK_ONLY, HIDE_ON_LOGIN, ORGANIZATION_ID), +-- CONSTRAINT `UK_2DAELWNIBJI49AVXSRTUF6XJ33` GLOBAL UNIQUE ON (PROVIDER_ALIAS, REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (INTERNAL_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql new file mode 100644 index 00000000..c1a9b52c --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_CONFIG +( + `IDENTITY_PROVIDER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (IDENTITY_PROVIDER_ID) REFERENCES IDENTITY_PROVIDER (INTERNAL_ID), + PRIMARY KEY (IDENTITY_PROVIDER_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql new file mode 100644 index 00000000..12260bd6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_MAPPER +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `IDP_ALIAS` Utf8 NOT NULL, + `IDP_MAPPER_NAME` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + + INDEX idx_id_prov_mapp_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql new file mode 100644 index 00000000..9ede15f6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS CREDENTIAL +( + `ID` Utf8 NOT NULL, + `SALT` String, + `TYPE` Utf8, + `USER_ID` Utf8, + `CREATED_DATE` Int64, + `USER_LABEL` Utf8, + `SECRET_DATA` Utf8, + `CREDENTIAL_DATA` Utf8, + `PRIORITY` Int32, + `VERSION` Int32 DEFAULT 0, + + INDEX idx_user_credential GLOBAL ON (USER_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql new file mode 100644 index 00000000..de0c8ecc --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS USER_ATTRIBUTE +( +-- maybe sybase-needs-something-here is not needed +-- because it was added in other changelog + `ID` Utf8 NOT NULL DEFAULT "sybase-needs-something-here", + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + `USER_ID` Utf8 NOT NULL, + `LONG_VALUE_HASH` String, + `LONG_VALUE_HASH_LOWER_CASE` String, + `LONG_VALUE` Utf8, + + INDEX idx_user_attribute GLOBAL ON (USER_ID), + INDEX idx_user_attribute_name GLOBAL ON (NAME, VALUE), + INDEX user_attr_long_values GLOBAL ON (LONG_VALUE_HASH, NAME), + INDEX user_attr_long_values_lower_case GLOBAL ON (LONG_VALUE_HASH_LOWER_CASE, NAME), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql new file mode 100644 index 00000000..fdae1321 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS REVOKED_TOKEN +( + `ID` Utf8 NOT NULL, + `EXPIRE` Int64 NOT NULL, + + INDEX idx_rev_token_on_expire GLOBAL ON (EXPIRE), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql new file mode 100644 index 00000000..e535262e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS CLIENT_AUTH_FLOW_BINDINGS +( + `CLIENT_ID` Utf8 NOT NULL, + `FLOW_ID` Utf8, + `BINDING_NAME` Utf8 NOT NULL, + + PRIMARY KEY (CLIENT_ID, BINDING_NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql new file mode 100644 index 00000000..7e058850 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS CLIENT_NODE_REGISTRATIONS +( + `CLIENT_ID` Utf8 NOT NULL, + `VALUE` Int32, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql new file mode 100644 index 00000000..74c0cff4 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS USER_REQUIRED_ACTION +( + `USER_ID` Utf8 NOT NULL, + `REQUIRED_ACTION` Utf8 NOT NULL DEFAULT " ", + + INDEX idx_user_reqactions GLOBAL ON (USER_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (REQUIRED_ACTION, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql new file mode 100644 index 00000000..a5f8eafa --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS OFFLINE_CLIENT_SESSION +( + `USER_SESSION_ID` Utf8 NOT NULL, + `CLIENT_ID` Utf8 NOT NULL, + `OFFLINE_FLAG` Utf8 NOT NULL, + `TIMESTAMP` Int32, + `DATA` Utf8, + `CLIENT_STORAGE_PROVIDER` Utf8 NOT NULL DEFAULT "local", + `EXTERNAL_CLIENT_ID` Utf8 NOT NULL DEFAULT "local", + `VERSION` Int32 DEFAULT 0, + + INDEX idx_offline_css_by_client GLOBAL ON (CLIENT_ID, OFFLINE_FLAG), + INDEX idx_offline_css_by_client_storage_provider GLOBAL ON (CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID, OFFLINE_FLAG), + PRIMARY KEY (USER_SESSION_ID, CLIENT_ID, CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID, OFFLINE_FLAG) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql new file mode 100644 index 00000000..a3682ef6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS OFFLINE_USER_SESSION +( + `USER_SESSION_ID` Utf8 NOT NULL, + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `CREATED_ON` Int32 NOT NULL, + `OFFLINE_FLAG` Utf8 NOT NULL, + `DATA` Utf8, + `LAST_SESSION_REFRESH` Int32 NOT NULL DEFAULT 0, + `BROKER_SESSION_ID` Utf8, + `VERSION` Int32 DEFAULT 0, + + INDEX idx_offline_uss_by_user GLOBAL ON (USER_ID, REALM_ID, OFFLINE_FLAG), + INDEX idx_offline_uss_by_last_session_refresh GLOBAL ON (REALM_ID, OFFLINE_FLAG, LAST_SESSION_REFRESH), + INDEX idx_offline_uss_by_broker_session_id GLOBAL ON (BROKER_SESSION_ID, REALM_ID), + PRIMARY KEY (USER_SESSION_ID, OFFLINE_FLAG) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql new file mode 100644 index 00000000..3f0d0be0 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS USER_GROUP_MEMBERSHIP +( + `GROUP_ID` Utf8 NOT NULL, + `USER_ID` Utf8 NOT NULL, + `MEMBERSHIP_TYPE` Utf8 NOT NULL, + + INDEX idx_user_group_mapping GLOBAL ON (USER_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (GROUP_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql new file mode 100644 index 00000000..59fcdcf2 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS KEYCLOAK_GROUP +( + `ID` Utf8 NOT NULL, + `NAME` Utf8, + `PARENT_GROUP` Utf8 NOT NULL, + `REALM_ID` Utf8, + `TYPE` Int32 NOT NULL DEFAULT 0, + `DESCRIPTION` Utf8, + +-- CONSTRAINT `SIBLING_NAMES` GLOBAL UNIQUE ON (REALM_ID, PARENT_GROUP, NAME), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql new file mode 100644 index 00000000..70e97eb8 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ORG +( + `ID` Utf8 NOT NULL, + `ENABLED` Bool NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `GROUP_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `DESCRIPTION` Utf8, + `ALIAS` Utf8 NOT NULL, + `REDIRECT_URL` Utf8, + +-- CONSTRAINT `UK_ORG_GROUP` GLOBAL UNIQUE ON (GROUP_ID), +-- CONSTRAINT `UK_ORG_NAME` GLOBAL UNIQUE ON (REALM_ID, NAME), +-- CONSTRAINT `UK_ORG_ALIAS` GLOBAL UNIQUE ON (REALM_ID, ALIAS), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql new file mode 100644 index 00000000..af703798 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS ORG_DOMAIN +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VERIFIED` Bool NOT NULL, + `ORG_ID` Utf8 NOT NULL, + + INDEX idx_org_domain_org_id GLOBAL ON (ORG_ID), + PRIMARY KEY (ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql new file mode 100644 index 00000000..57fa7fd8 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS USER_CONSENT +( + `ID` Utf8 NOT NULL, + `CLIENT_ID` Utf8, + `USER_ID` Utf8 NOT NULL, + `CREATED_DATE` Int64, + `LAST_UPDATED_DATE` Int64, + `CLIENT_STORAGE_PROVIDER` Utf8, + `EXTERNAL_CLIENT_ID` Utf8, + + INDEX idx_user_consent GLOBAL ON (USER_ID), +-- CONSTRAINT `UK_LOCAL_CONSENT` GLOBAL UNIQUE ON (CLIENT_ID, USER_ID), +-- CONSTRAINT `UK_EXTERNAL_CONSENT` GLOBAL UNIQUE ON (CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID, USER_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql new file mode 100644 index 00000000..8e415fb8 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS USER_CONSENT_CLIENT_SCOPE +( + `USER_CONSENT_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + + INDEX idx_usconsent_clscope GLOBAL ON (USER_CONSENT_ID), + INDEX idx_usconsent_scope_id GLOBAL ON (SCOPE_ID), +-- FOREIGN KEY (USER_CONSENT_ID) REFERENCES USER_CONSENT (ID), + PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql new file mode 100644 index 00000000..b7de43cf --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS FEDERATED_IDENTITY +( + `IDENTITY_PROVIDER` Utf8 NOT NULL, + `REALM_ID` Utf8, + `FEDERATED_USER_ID` Utf8, + `FEDERATED_USERNAME` Utf8, + `TOKEN` Utf8, + `USER_ID` Utf8 NOT NULL, + + INDEX idx_fedidentity_user GLOBAL ON (USER_ID), + INDEX idx_fedidentity_feduser GLOBAL ON (FEDERATED_USER_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), + PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql new file mode 100644 index 00000000..6d9f2725 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS FED_USER_CONSENT +( + `ID` Utf8 NOT NULL, + `CLIENT_ID` Utf8, + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `CREATED_DATE` Int64, + `LAST_UPDATED_DATE` Int64, + `CLIENT_STORAGE_PROVIDER` Utf8, + `EXTERNAL_CLIENT_ID` Utf8, + + INDEX idx_fu_consent_ru GLOBAL ON (REALM_ID, USER_ID), + INDEX idx_fu_cnsnt_ext GLOBAL ON (USER_ID, CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID), + INDEX idx_fu_consent GLOBAL ON (USER_ID, CLIENT_ID), + PRIMARY KEY (ID) + ); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql new file mode 100644 index 00000000..b1463d1f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS FED_USER_CONSENT_CL_SCOPE +( + `USER_CONSENT_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + + PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql new file mode 100644 index 00000000..5e11a567 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS FED_USER_CREDENTIAL +( + `ID` Utf8 NOT NULL, + `SALT` String, + `TYPE` Utf8, + `CREATED_DATE` Int64, + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `USER_LABEL` Utf8, + `SECRET_DATA` Utf8, + `CREDENTIAL_DATA` Utf8, + `PRIORITY` Int32, + + INDEX idx_fu_credential GLOBAL ON (USER_ID, TYPE), + INDEX idx_fu_credential_ru GLOBAL ON (REALM_ID, USER_ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql new file mode 100644 index 00000000..66b65f47 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FED_USER_GROUP_MEMBERSHIP +( + `GROUP_ID` Utf8 NOT NULL, + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + + INDEX idx_fu_group_membership GLOBAL ON (USER_ID, GROUP_ID), + INDEX idx_fu_group_membership_ru GLOBAL ON (REALM_ID, USER_ID), + PRIMARY KEY (GROUP_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql new file mode 100644 index 00000000..719c676b --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FED_USER_REQUIRED_ACTION +( + `REQUIRED_ACTION` Utf8 NOT NULL DEFAULT " ", + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + + INDEX idx_fu_required_action GLOBAL ON (USER_ID, REQUIRED_ACTION), + INDEX idx_fu_required_action_ru GLOBAL ON (REALM_ID, USER_ID), + PRIMARY KEY (REQUIRED_ACTION, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql new file mode 100644 index 00000000..d81d4a23 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FED_USER_ROLE_MAPPING +( + `ROLE_ID` Utf8 NOT NULL, + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + + INDEX idx_fu_role_mapping GLOBAL ON (USER_ID, ROLE_ID), + INDEX idx_fu_role_mapping_ru GLOBAL ON (REALM_ID, USER_ID), + PRIMARY KEY (ROLE_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql new file mode 100644 index 00000000..8ff82ef6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS FEDERATED_USER +( + `ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `REALM_ID` Utf8 NOT NULL, + + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql new file mode 100644 index 00000000..4e90ef13 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS BROKER_LINK +( + `IDENTITY_PROVIDER` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `REALM_ID` Utf8 NOT NULL, + `BROKER_USER_ID` Utf8, + `BROKER_USERNAME` Utf8, + `TOKEN` Utf8, + `USER_ID` Utf8 NOT NULL, + + PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql new file mode 100644 index 00000000..d98a215f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS FED_USER_ATTRIBUTE +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `USER_ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `VALUE` Utf8, + `LONG_VALUE_HASH` String, + `LONG_VALUE_HASH_LOWER_CASE` String, + `LONG_VALUE` Utf8, + + INDEX idx_fu_attribute GLOBAL ON (USER_ID, REALM_ID, NAME), + INDEX fed_user_attr_long_values GLOBAL ON (LONG_VALUE_HASH, NAME), + INDEX fed_user_attr_long_values_lower_case GLOBAL ON (LONG_VALUE_HASH_LOWER_CASE, NAME), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql new file mode 100644 index 00000000..f9a82f37 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS GROUP_ATTRIBUTE +( + `ID` Utf8 NOT NULL DEFAULT "sybase-needs-something-here", + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + `GROUP_ID` Utf8 NOT NULL, + + INDEX idx_group_attr_group GLOBAL ON (GROUP_ID), + INDEX idx_group_att_by_name_value GLOBAL ON (NAME, VALUE), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql new file mode 100644 index 00000000..3c7e966e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS GROUP_ROLE_MAPPING +( + `ROLE_ID` Utf8 NOT NULL, + `GROUP_ID` Utf8 NOT NULL, + + INDEX idx_group_role_mapp_group GLOBAL ON (GROUP_ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (ROLE_ID, GROUP_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql new file mode 100644 index 00000000..8d4d9375 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_SERVER +( + `ID` Utf8 NOT NULL, + `ALLOW_RS_REMOTE_MGMT` Bool NOT NULL DEFAULT false, + `POLICY_ENFORCE_MODE` Int16 NOT NULL, + `DECISION_STRATEGY` Int16 NOT NULL DEFAULT 1, + + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql new file mode 100644 index 00000000..e5e27023 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_POLICY +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `DESCRIPTION` Utf8, + `TYPE` Utf8 NOT NULL, + `DECISION_STRATEGY` Int16, + `LOGIC` Int16, + `RESOURCE_SERVER_ID` Utf8 NOT NULL, + `OWNER` Utf8, + + INDEX idx_res_serv_pol_res_serv GLOBAL ON (RESOURCE_SERVER_ID), +-- todo maybe add unique index `ON (NAME, RESOURCE_SERVER_ID)` +-- CONSTRAINT `UK_FRSRPT700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (NAME, RESOURCE_SERVER_ID), +-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql new file mode 100644 index 00000000..60acb552 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_RESOURCE +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `TYPE` Utf8, + `ICON_URI` Utf8, + `OWNER` Utf8 NOT NULL, + `RESOURCE_SERVER_ID` Utf8 NOT NULL, + `OWNER_MANAGED_ACCESS` Bool NOT NULL DEFAULT false, + `DISPLAY_NAME` Utf8, + + INDEX idx_res_srv_res_res_srv GLOBAL ON (RESOURCE_SERVER_ID), +-- TODO: maybe create UNIQUE index `ON (NAME, OWNER, RESOURCE_SERVER_ID),` +-- CONSTRAINT `UK_FRSR6T700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (NAME, OWNER, RESOURCE_SERVER_ID), +-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql new file mode 100644 index 00000000..07863a1e --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_ATTRIBUTE +( + `ID` Utf8 NOT NULL DEFAULT "sybase-needs-something-here", + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + `RESOURCE_ID` Utf8 NOT NULL, + +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql new file mode 100644 index 00000000..7b594e05 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_POLICY +( + `RESOURCE_ID` Utf8 NOT NULL, + `POLICY_ID` Utf8 NOT NULL, + + INDEX idx_res_policy_policy GLOBAL ON (POLICY_ID), +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), +-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID), + PRIMARY KEY (RESOURCE_ID, POLICY_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql new file mode 100644 index 00000000..b3176409 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_SCOPE +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `ICON_URI` Utf8, + `RESOURCE_SERVER_ID` Utf8 NOT NULL, + `DISPLAY_NAME` Utf8, + + INDEX idx_res_srv_scope_res_srv GLOBAL ON (RESOURCE_SERVER_ID), +-- TODO: maybe create UNIQUE index `ON (NAME, RESOURCE_SERVER_ID)` +-- CONSTRAINT `UK_FRSRST700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (NAME, RESOURCE_SERVER_ID), +-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql new file mode 100644 index 00000000..ce6f9b30 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_SCOPE +( + `RESOURCE_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + + INDEX idx_res_scope_scope GLOBAL ON (SCOPE_ID), +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES RESOURCE_SERVER_SCOPE (ID), + PRIMARY KEY (RESOURCE_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql new file mode 100644 index 00000000..93964d43 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_PERM_TICKET +( + `ID` Utf8 NOT NULL, + `OWNER` Utf8 NOT NULL, + `REQUESTER` Utf8 NOT NULL, + `CREATED_TIMESTAMP` Int64 NOT NULL, + `GRANTED_TIMESTAMP` Int64, + `RESOURCE_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8, + `RESOURCE_SERVER_ID` Utf8 NOT NULL, + `POLICY_ID` Utf8, + + INDEX idx_perm_ticket_requester GLOBAL ON (REQUESTER), + INDEX idx_perm_ticket_owner GLOBAL ON (OWNER), +-- TODO: maybe create UNIQUE index `ON (OWNER, REQUESTER, RESOURCE_SERVER_ID, RESOURCE_ID, SCOPE_ID),` +-- CONSTRAINT `UK_FRSR6T700S9V50BU18WS5PMT` GLOBAL UNIQUE ON (OWNER, REQUESTER, RESOURCE_SERVER_ID, RESOURCE_ID, SCOPE_ID), +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES RESOURCE_SERVER_SCOPE (ID), +-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID), +-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql new file mode 100644 index 00000000..1167b7b7 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_URIS +( + `RESOURCE_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), + PRIMARY KEY (RESOURCE_ID, VALUE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql new file mode 100644 index 00000000..27866df4 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS POLICY_CONFIG +( + `POLICY_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + +-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID), + PRIMARY KEY (POLICY_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql new file mode 100644 index 00000000..21f71a52 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS IDP_MAPPER_CONFIG +( + `IDP_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (IDP_MAPPER_ID) REFERENCES IDENTITY_PROVIDER_MAPPER (ID), + PRIMARY KEY (IDP_MAPPER_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql new file mode 100644 index 00000000..a8b17280 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS CLIENT_INITIAL_ACCESS +( + `ID` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + `TIMESTAMP` Int32, + `EXPIRATION` Int32, + `COUNT` Int32, + `REMAINING_COUNT` Int32, + + INDEX idx_client_init_acc_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql new file mode 100644 index 00000000..ca8b018f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_PROVIDER +( + `ID` Utf8 NOT NULL, + `CHANGED_SYNC_PERIOD` Int32, + `DISPLAY_NAME` Utf8, + `FULL_SYNC_PERIOD` Int32, + `LAST_SYNC` Int32, + `PRIORITY` Int32, + `PROVIDER_NAME` Utf8, + `REALM_ID` Utf8, + + INDEX idx_usr_fed_prv_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql new file mode 100644 index 00000000..ae7a5d95 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_CONFIG +( + `USER_FEDERATION_PROVIDER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (USER_FEDERATION_PROVIDER_ID) REFERENCES USER_FEDERATION_PROVIDER (ID), + PRIMARY KEY (USER_FEDERATION_PROVIDER_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql new file mode 100644 index 00000000..bd167b1a --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER +( + `ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `FEDERATION_PROVIDER_ID` Utf8 NOT NULL, + `FEDERATION_MAPPER_TYPE` Utf8 NOT NULL, + `REALM_ID` Utf8 NOT NULL, + + INDEX idx_usr_fed_map_fed_prv GLOBAL ON (FEDERATION_PROVIDER_ID), + INDEX idx_usr_fed_map_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (FEDERATION_PROVIDER_ID) REFERENCES USER_FEDERATION_PROVIDER (ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql new file mode 100644 index 00000000..ae243527 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER_CONFIG +( + `USER_FEDERATION_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + +-- FOREIGN KEY (USER_FEDERATION_MAPPER_ID) REFERENCES USER_FEDERATION_MAPPER (ID), + PRIMARY KEY (USER_FEDERATION_MAPPER_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql new file mode 100644 index 00000000..40e29ab3 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS ADMIN_EVENT_ENTITY +( + `ID` Utf8 NOT NULL, + `ADMIN_EVENT_TIME` Int64, + `REALM_ID` Utf8, + `OPERATION_TYPE` Utf8, + `AUTH_REALM_ID` Utf8, + `AUTH_CLIENT_ID` Utf8, + `AUTH_USER_ID` Utf8, + `IP_ADDRESS` Utf8, + `RESOURCE_PATH` Utf8, + `REPRESENTATION` Utf8, + `ERROR` Utf8, + `RESOURCE_TYPE` Utf8, + `DETAILS_JSON` Utf8, + + INDEX idx_admin_event_time GLOBAL ON (REALM_ID, ADMIN_EVENT_TIME), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql new file mode 100644 index 00000000..ae303452 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS ASSOCIATED_POLICY +( + `POLICY_ID` Utf8 NOT NULL, + `ASSOCIATED_POLICY_ID` Utf8 NOT NULL, + + INDEX idx_assoc_pol_assoc_pol_id GLOBAL ON (ASSOCIATED_POLICY_ID), +-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID), +-- FOREIGN KEY (ASSOCIATED_POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID), + PRIMARY KEY (POLICY_ID, ASSOCIATED_POLICY_ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql new file mode 100644 index 00000000..10b2d834 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS SCOPE_POLICY +( + `SCOPE_ID` Utf8 NOT NULL, + `POLICY_ID` Utf8 NOT NULL, + + INDEX idx_scope_policy_policy GLOBAL ON (POLICY_ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES RESOURCE_SERVER_SCOPE (ID), +-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID), + PRIMARY KEY (SCOPE_ID, POLICY_ID) +); diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml new file mode 100644 index 00000000..0cca7e61 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt new file mode 100644 index 00000000..9ee31df9 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt @@ -0,0 +1,72 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil.ydbRetryableException + +class YdbEntityManagerProxyTest { + + @Test + fun retryableErrorFromDelegateIsMappedTo503() { + val exception = ydbRetryableException("transaction conflict") + val em = mock() + + whenever(em.flush()).thenAnswer { throw exception } + val proxy = YdbEntityManagerProxy.create(em) + + val ex = assertThrows(WebApplicationException::class.java) { proxy.flush() } + + assertEquals(503, ex.response.status) + assertEquals("application", ex.response.mediaType?.type) + assertEquals("json", ex.response.mediaType?.subtype) + assertSame(exception, ex.cause) + val body = ex.response.entity as String + assertTrue(body.contains("\"error\":\"ydb_retryable\"")) + assertTrue(body.contains("Transaction aborted due to contention, please retry")) + } + + @Test + fun retryableErrorInCauseChainIsMappedTo503() { + val exception = ydbRetryableException() + val em = mock() + + whenever(em.flush()).thenThrow(RuntimeException("wrapper", exception)) + + val proxy = YdbEntityManagerProxy.create(em) + + val ex = assertThrows(WebApplicationException::class.java) { proxy.flush() } + + assertEquals(503, ex.response.status) + assertSame(exception, ex.cause?.cause) + } + + @Test + fun nonRetryableErrorIsRethrown() { + val exception = IllegalStateException("db error") + val em = mock() + whenever(em.flush()).thenThrow(exception) + + val proxy = YdbEntityManagerProxy.create(em) + + val ex = assertThrows(IllegalStateException::class.java) { proxy.flush() } + + assertSame(exception, ex) + } + + @Test + fun successfulInvocationIsDelegated() { + val em = mock() + whenever(em.isOpen).thenReturn(true) + + val proxy = YdbEntityManagerProxy.create(em) + + assertTrue(proxy.isOpen) + } +} diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt new file mode 100644 index 00000000..35b276f5 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt @@ -0,0 +1,87 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.persistence.EntityTransaction +import jakarta.persistence.PersistenceException +import jakarta.ws.rs.WebApplicationException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.keycloak.models.ModelException +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.doReturn +import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil + +class YdbJpaKeycloakTransactionTest { + + @Test + fun retryableCommitErrorIsMappedTo503() { + val retryable = YdbRetryableExceptionUtil.ydbRetryableException("commit conflict") + val tx = mock() + doAnswer { throw retryable }.whenever(tx).commit() + val em = mock { + on { transaction } doReturn tx + } + val transaction = YdbJpaKeycloakTransaction(em) + + val ex = assertThrows(WebApplicationException::class.java) { transaction.commit() } + + assertEquals(503, ex.response.status) + assertEquals("application", ex.response.mediaType?.type) + assertEquals("json", ex.response.mediaType?.subtype) + assertSame(retryable, ex.cause) + val body = ex.response.entity as String + assertTrue(body.contains("\"error\":\"ydb_retryable\"")) + assertTrue(body.contains("Transaction aborted, please retry")) + } + + @Test + fun retryableErrorWrappedInPersistenceExceptionIsMappedTo503() { + val retryable = YdbRetryableExceptionUtil.ydbRetryableException() + val tx = mock { + on { commit() } doThrow PersistenceException(retryable) + } + val em = mock { + on { transaction } doReturn tx + } + val transaction = YdbJpaKeycloakTransaction(em) + + val ex = assertThrows(WebApplicationException::class.java) { transaction.commit() } + + assertEquals(503, ex.response.status) + assertTrue(ex.cause is ModelException) + assertSame(retryable, ex.cause?.cause) + } + + @Test + fun nonRetryableCommitErrorIsRethrown() { + val failure = IllegalStateException("commit failed") + val tx = mock { + on { commit() } doThrow failure + } + val em = mock { + on { transaction } doReturn tx + } + val transaction = YdbJpaKeycloakTransaction(em) + + val ex = assertThrows(IllegalStateException::class.java) { transaction.commit() } + + assertSame(failure, ex) + } + + @Test + fun successfulCommitDelegatesToEntityManager() { + val tx = mock() + val em = mock { + on { transaction } doReturn tx + } + val transaction = YdbJpaKeycloakTransaction(em) + + transaction.commit() + } +} diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt new file mode 100644 index 00000000..7d4ccb4a --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt @@ -0,0 +1,51 @@ +package tech.ydb.keycloak.error + +import jakarta.persistence.PersistenceException +import jakarta.ws.rs.core.Response +import org.hibernate.exception.GenericJDBCException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.keycloak.models.KeycloakSession +import org.mockito.kotlin.mock +import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil +import tech.ydb.keycloak.utils.YdbRetryableResponses + +class YdbRetryableExceptionMappersTest { + + @Test + fun genericJdbcExceptionMapperReturns503ForYdbRetryableCause() { + val retryable = YdbRetryableExceptionUtil.ydbRetryableException() + val mapper = YdbRetryableGenericJdbcExceptionMapper() + injectSession(mapper, mock()) + + val response = mapper.toResponse(GenericJDBCException("jdbc error", retryable)) + + assertEquals(503, response.status) + assertRetryableBody(response) + } + + @Test + fun persistenceExceptionMapperReturns503ForYdbRetryableCause() { + val retryable = YdbRetryableExceptionUtil.ydbRetryableException() + val mapper = YdbRetryablePersistenceExceptionMapper() + injectSession(mapper, mock()) + + val response = mapper.toResponse(PersistenceException(retryable)) + + assertEquals(503, response.status) + assertRetryableBody(response) + } + + private fun assertRetryableBody(response: Response) { + val body = response.entity as String + assertTrue(body.contains(YdbRetryableResponses.ERROR_CODE)) + assertTrue(body.contains(YdbRetryableResponses.CONTENTION_DESCRIPTION)) + } + + private fun injectSession(mapper: Any, session: KeycloakSession) { + val field = mapper.javaClass.getDeclaredField("session") + field.isAccessible = true + field.set(mapper, session) + } +} diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt new file mode 100644 index 00000000..c919c0d5 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt @@ -0,0 +1,14 @@ +package tech.ydb.keycloak.testsupport + +import tech.ydb.core.Status +import tech.ydb.core.StatusCode +import tech.ydb.core.UnexpectedResultException +import tech.ydb.jdbc.exception.ExceptionFactory +import tech.ydb.jdbc.exception.YdbRetryableException + +object YdbRetryableExceptionUtil { + fun ydbRetryableException(message: String = "contention"): YdbRetryableException { + val unexpected = UnexpectedResultException(message, Status.of(StatusCode.ABORTED)) + return ExceptionFactory.createException(message, unexpected) as YdbRetryableException + } +} diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt new file mode 100644 index 00000000..e118f337 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt @@ -0,0 +1,38 @@ +package tech.ydb.keycloak.utils + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil + +class YdbErrorUtilsTest { + + @Test + fun returnsTrueForYdbRetryableException() { + assertTrue(isYdbRetryable(YdbRetryableExceptionUtil.ydbRetryableException())) + } + + @Test + fun returnsTrueWhenYdbRetryableExceptionIsCause() { + val retryable = YdbRetryableExceptionUtil.ydbRetryableException() + val wrapped = RuntimeException("wrapper", retryable) + assertTrue(isYdbRetryable(wrapped)) + } + + @Test + fun returnsTrueForDeepCauseChain() { + val retryable = YdbRetryableExceptionUtil.ydbRetryableException() + val wrapped = IllegalStateException("outer", RuntimeException("middle", retryable)) + assertTrue(isYdbRetryable(wrapped)) + } + + @Test + fun returnsFalseForUnrelatedException() { + assertFalse(isYdbRetryable(RuntimeException("fail"))) + } + + @Test + fun returnsFalseForExceptionWithoutRetryableCause() { + assertFalse(isYdbRetryable(RuntimeException("fail", IllegalStateException("inner")))) + } +} diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt new file mode 100644 index 00000000..b53c73c7 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt @@ -0,0 +1,23 @@ +package tech.ydb.keycloak.utils + +import jakarta.ws.rs.core.Response +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil + +class YdbRetryableResponsesTest { + + @Test + fun build503SetsExpectedHeadersAndBody() { + val cause = YdbRetryableExceptionUtil.ydbRetryableException() + val response = YdbRetryableResponses.build503(cause, YdbRetryableResponses.CONTENTION_DESCRIPTION) + + assertEquals(Response.Status.SERVICE_UNAVAILABLE.statusCode, response.status) + assertEquals("application", response.mediaType?.type) + assertEquals("json", response.mediaType?.subtype) + val body = response.entity as String + assertTrue(body.contains("\"error\":\"${YdbRetryableResponses.ERROR_CODE}\"")) + assertTrue(body.contains(YdbRetryableResponses.CONTENTION_DESCRIPTION)) + } +} diff --git a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf new file mode 100644 index 00000000..13c5fa3b --- /dev/null +++ b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf @@ -0,0 +1,28 @@ +# Keycloak configuration for remote YDB (not in Docker) +# Mount this as /opt/keycloak/conf/keycloak.conf + +# --- YDB / JPA (this extension) --- +# REQUIRED: set YDB_JDBC_URL when starting the container (see docker-compose-remote-ydb.yml). +# Example: jdbc:ydb:grpcs://.../path +spi-connections-jpa-ydb-jdbc-url=${YDB_JDBC_URL} +spi-connections-jpa-ydb-show-sql=false + +# --- HikariCP connection pool --- +# spi-connections-jpa-ydb-pool-size=50 +# spi-connections-jpa-ydb-min-idle=10 +# spi-connections-jpa-ydb-connection-timeout=30000 +# spi-connections-jpa-ydb-idle-timeout=600000 +# spi-connections-jpa-ydb-max-lifetime=1800000 + +# Disable default Quarkus JPA and Liquibase so only this extension is used +spi-connections-jpa-quarkus-enabled=false +spi-connections-liquibase-quarkus-enabled=false + +# --- Bootstrap admin (Keycloak 26.x) --- +bootstrap-admin-username=admin +bootstrap-admin-password=admin + +# --- Proxy settings (retry-proxy in front) --- +proxy-headers=forwarded +http-enabled=true +hostname=http://localhost:9090 diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf new file mode 100644 index 00000000..c841b2f9 --- /dev/null +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -0,0 +1,31 @@ +# Keycloak configuration for YDB extension (Docker Compose) +# Mount this as /opt/keycloak/conf/keycloak.conf + +# --- YDB / JPA (this extension) --- +# JDBC URL for YDB (required). Override with env for different hosts, e.g.: +spi-connections-jpa-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local +spi-connections-jpa-ydb-show-sql=false +# spi-connections-jpa-ydb-format-sql=true + +# --- HikariCP connection pool --- +# spi-connections-jpa-ydb-pool-size=50 +# spi-connections-jpa-ydb-min-idle=10 +# spi-connections-jpa-ydb-connection-timeout=30000 +# spi-connections-jpa-ydb-idle-timeout=600000 +# spi-connections-jpa-ydb-max-lifetime=1800000 + +# --- YDB Liquibase provider --- +# spi-connections-liquibase-ydb-liquibase-index-creation-threshold=300000 + +# Disable default Quarkus JPA and Liquibase so only this extension is used +spi-connections-jpa-quarkus-enabled=false +spi-connections-liquibase-quarkus-enabled=false + +# --- Bootstrap admin (Keycloak 26.x) --- +bootstrap-admin-username=admin +bootstrap-admin-password=admin + +# --- Proxy settings (retry-proxy in front) --- +proxy-headers=forwarded +http-enabled=true +hostname=http://0.0.0.0:9090 diff --git a/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml new file mode 100644 index 00000000..944cfed3 --- /dev/null +++ b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml @@ -0,0 +1,40 @@ +version: '3.4' + +networks: + keycloak: + driver: bridge + +services: + remote-ydb-keycloak: + image: quay.io/keycloak/keycloak:26.4.7 + container_name: remote-ydb-keycloak + expose: + - '8080' + volumes: + - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + - ./conf/keycloak-remote-ydb.conf:/opt/keycloak/conf/keycloak.conf + entrypoint: /opt/keycloak/bin/kc.sh + command: > + -v start-dev + --cache=local + environment: + - YDB_JDBC_URL=${YDB_JDBC_URL:?Set YDB_JDBC_URL, e.g. jdbc:ydb:grpc://host.docker.internal:2136/local} + networks: + - keycloak + + remote-ydb-retry-proxy: + build: + context: ../retry-proxy + container_name: remote-ydb-retry-proxy + environment: + - TARGET_URL=http://remote-ydb-keycloak:8080 + - MAX_RETRIES=10 + - BASE_DELAY_MS=50 + - MAX_DELAY_MS=2000 + - LISTEN_PORT=8080 + ports: + - '9090:8080' + networks: + - keycloak + depends_on: + - remote-ydb-keycloak diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml new file mode 100644 index 00000000..90ff5f6f --- /dev/null +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.4' + +volumes: + ydb_data: + driver: local + ydb_certs: + driver: local + +networks: + keycloak: + driver: bridge + +services: + ydb: + image: ydbplatform/local-ydb:latest + platform: linux/amd64 + ports: + - '2135:2135' # GRPC TLS + - '2136:2136' # GRPC + - '8765:8765' # Monitoring UI + - '9092:9092' # Kafka Proxy + volumes: + - 'ydb_certs:/ydb_certs' + - 'ydb_data:/ydb_data' + networks: + - keycloak + environment: + - GRPC_TLS_PORT=2135 + - GRPC_PORT=2136 + - MON_PORT=8765 + - YDB_KAFKA_PROXY_PORT=9092 + healthcheck: + test: [ "CMD-SHELL", "/bin/sh /health_check" ] + interval: 10s + timeout: 5s + retries: 5 + + ydb-keycloak: + image: quay.io/keycloak/keycloak:26.4.7 + container_name: ydb-keycloak + expose: + - '8080' + volumes: + - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + - ./conf/keycloak.conf:/opt/keycloak/conf/keycloak.conf + entrypoint: /opt/keycloak/bin/kc.sh + command: > + -v start-dev + --cache=local + networks: + - keycloak + depends_on: + ydb: + condition: service_healthy + + retry-proxy: + build: + context: ../retry-proxy + environment: + - TARGET_URL=http://ydb-keycloak:8080 + - MAX_RETRIES=10 + - BASE_DELAY_MS=50 + - MAX_DELAY_MS=2000 + - LISTEN_PORT=8080 + ports: + - 9090:8080 + networks: + - keycloak + depends_on: + ydb-keycloak: + condition: service_started + restart: on-failure diff --git a/keycloak-ydb-extension/load-test/.gitignore b/keycloak-ydb-extension/load-test/.gitignore new file mode 100644 index 00000000..4ca468ac --- /dev/null +++ b/keycloak-ydb-extension/load-test/.gitignore @@ -0,0 +1,2 @@ +lib/ +results/ diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md new file mode 100644 index 00000000..bf66e636 --- /dev/null +++ b/keycloak-ydb-extension/load-test/README.md @@ -0,0 +1,249 @@ +# Load Testing + +Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). + +Supports two infrastructure configurations: + +- **Keycloak + Local YDB** — YDB in Docker, retry-proxy in front of Keycloak +- **Keycloak + Remote YDB** — external YDB instance, retry-proxy in front of Keycloak + +## Prerequisites + +- Java 21+ +- Python 3 +- Docker + Docker Compose + +## Quick Start + +## 1. Download keycloak-benchmark + +```bash +./prepare.sh +``` + +Downloads the Gatling benchmark JARs from GitHub releases into `lib/`. +To use a specific version: + +```bash +./prepare.sh 26.4.0-SNAPSHOT +``` + +## 2. Start infrastructure + +All commands below are run from the `keycloak-ydb-extension/` root. + +### Option A: Keycloak + Local YDB + +```bash +../run-keycloak-with-ydb.sh +``` + +This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Keycloak + retry-proxy). + +Wait for Keycloak to start (~30-60s). Check logs: + +```bash +docker compose -f docker/docker-compose.yml logs -f ydb-keycloak +``` + +| Service | URL | +|----------------------------|-----------------------| +| Keycloak (via retry-proxy) | http://localhost:9090 | +| YDB Monitoring | http://localhost:8765 | + +### Option B: Keycloak + Remote YDB + +Start YDB separately, e.g.: + +```bash +docker run -d --rm --name ydb-local -h localhost \ + --platform linux/amd64 \ + -p 2135:2135 -p 2136:2136 -p 8765:8765 \ + -v $(pwd)/ydb_certs:/ydb_certs -v $(pwd)/ydb_data:/ydb_data \ + -e GRPC_TLS_PORT=2135 -e GRPC_PORT=2136 -e MON_PORT=8765 \ + ydbplatform/local-ydb:latest +``` + +Then start Keycloak + retry-proxy: + +```bash +YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ + docker compose -f docker/docker-compose-remote-ydb.yml up -d --build +``` + +For a cloud YDB instance: + +```bash +YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/..." \ + docker compose -f docker/docker-compose-remote-ydb.yml up -d --build +``` + +| Service | URL | +|----------------------------|-----------------------| +| Keycloak (via retry-proxy) | http://localhost:9090 | + +Admin credentials for all options: `admin` / `admin` + +### Comparison with other databases + +For benchmarking Keycloak with other databases (PostgreSQL, MySQL, etc.), use the setups from the keycloak-benchmark repository: +[keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark/tree/main/provision) + +## 3. Setup test realm + +```bash +python3 setup-test-realm.py +``` + +Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. + +## 4. Run load test + +All commands are run from the `load-test/` directory. + +First, build the classpath: + +```bash +CLASSPATH=$(find lib -name '*.jar' | tr '\n' ':') +``` + +--- + +### Admin scenarios using service account (CreateUsers, CreateDeleteUsers, CreateClients, CreateDeleteClients) + +These scenarios authenticate via the `gatling` service account created by `setup-test-realm.py`. + +```bash +java -server -Xmx1G \ + -Dserver-url=http://localhost:9090 \ + -Drealm-name=test-realm \ + -Dclient-id=gatling \ + -Dclient-secret=setup-for-benchmark \ + -Dusers-per-sec=10 \ + -Dmeasurement=60 \ + -cp "$CLASSPATH" \ + io.gatling.app.Gatling \ + -rf results \ + -s keycloak.scenario.admin.CreateUsers +``` + +Swap `-s` for any of: +- `keycloak.scenario.admin.CreateUsers` +- `keycloak.scenario.admin.CreateDeleteUsers` +- `keycloak.scenario.admin.CreateClients` +- `keycloak.scenario.admin.CreateDeleteClients` + +--- + +### Admin scenarios using admin account (CreateRealms, CreateDeleteRealms, ListSessions) + +These scenarios authenticate as the Keycloak admin user. + +```bash +java -server -Xmx1G \ + -Dserver-url=http://localhost:9090 \ + -Drealm-name=test-realm \ + -Dclient-id=gatling \ + -Dclient-secret=setup-for-benchmark \ + -Dadmin-username=admin \ + -Dadmin-password=admin \ + -Dusers-per-sec=10 \ + -Dmeasurement=60 \ + -cp "$CLASSPATH" \ + io.gatling.app.Gatling \ + -rf results \ + -s keycloak.scenario.admin.CreateRealms +``` + +Swap `-s` for any of: +- `keycloak.scenario.admin.CreateRealms` +- `keycloak.scenario.admin.CreateDeleteRealms` +- `keycloak.scenario.admin.ListSessions` + +--- + +### Authentication scenario: Client Credentials (ClientSecret) + +Authenticates via `client_credentials` grant — no user login required. + +```bash +java -server -Xmx1G \ + -Dserver-url=http://localhost:9090 \ + -Drealm-name=test-realm \ + -Dclient-id=gatling \ + -Dclient-secret=setup-for-benchmark \ + -Dusers-per-sec=10 \ + -Dmeasurement=60 \ + -cp "$CLASSPATH" \ + io.gatling.app.Gatling \ + -rf results \ + -s keycloak.scenario.authentication.ClientSecret +``` + +--- + +### Authentication scenarios: User Login (AuthorizationCode, LoginUserPassword) + +These scenarios simulate real user logins. They require `http://0.0.0.0:9090` instead of `localhost` — +Gatling refuses to send secure cookies to localhost with Keycloak 26 +(see [keycloak-benchmark#945](https://github.com/keycloak/keycloak-benchmark/issues/945)). +Also make sure `hostname` in `docker/conf/keycloak.conf` matches this address (see retry-proxy README). + +By default, users `user-0`, `user-1`, ... with passwords `user-0-password`, `user-1-password`, ... are used (created by `setup-test-realm.py`). + +```bash +java -server -Xmx1G \ + -Dserver-url=http://0.0.0.0:9090 \ + -Drealm-name=test-realm \ + -Dclient-id=gatling \ + -Dclient-secret=setup-for-benchmark \ + -Dusers-per-sec=10 \ + -Dmeasurement=60 \ + -cp "$CLASSPATH" \ + io.gatling.app.Gatling \ + -rf results \ + -s keycloak.scenario.authentication.AuthorizationCode +``` + +Swap `-s` for any of: +- `keycloak.scenario.authentication.AuthorizationCode` +- `keycloak.scenario.authentication.LoginUserPassword` + +Results are saved to `results/` with Gatling HTML reports. + +## 5. Cleanup between runs + +Delete all users from test-realm: + +```bash +python3 delete-all-users.py +``` + +## Available Scenarios + +| Scenario | Auth method | localhost ok? | +|---|---|:---:| +| `keycloak.scenario.admin.CreateUsers` | service account | yes | +| `keycloak.scenario.admin.CreateDeleteUsers` | service account | yes | +| `keycloak.scenario.admin.CreateClients` | service account | yes | +| `keycloak.scenario.admin.CreateDeleteClients` | service account | yes | +| `keycloak.scenario.admin.CreateRealms` | admin account | yes | +| `keycloak.scenario.admin.CreateDeleteRealms` | admin account | yes | +| `keycloak.scenario.admin.ListSessions` | admin account | yes | +| `keycloak.scenario.authentication.ClientSecret` | client credentials | yes | +| `keycloak.scenario.authentication.AuthorizationCode` | user login | **no** — use `0.0.0.0` | +| `keycloak.scenario.authentication.LoginUserPassword` | user login | **no** — use `0.0.0.0` | + +Full list of scenarios: +[keycloak-benchmark/scenario](https://github.com/keycloak/keycloak-benchmark/tree/main/benchmark/src/main/scala/keycloak/scenario) + +## Directory Structure + +``` +load-test/ + prepare.sh # Downloads keycloak-benchmark from GitHub + setup-test-realm.py # Creates test realm, clients, users + delete-all-users.py # Deletes all users from realm + lib/ # Benchmark JARs (gitignored) + results/ # Gatling reports (gitignored) +``` diff --git a/keycloak-ydb-extension/load-test/delete-all-users.py b/keycloak-ydb-extension/load-test/delete-all-users.py new file mode 100644 index 00000000..149af7e5 --- /dev/null +++ b/keycloak-ydb-extension/load-test/delete-all-users.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Deletes all users from test-realm in Keycloak (batched). + +Usage: + python3 delete-all-users.py [KC_URL] [REALM] + KC_URL defaults to http://localhost:9090 + REALM defaults to test-realm +""" + +import sys +import urllib.request +import urllib.parse +import urllib.error +import json + +KC_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:9090" +REALM = sys.argv[2] if len(sys.argv) > 2 else "test-realm" +ADMIN_USER = "admin" +ADMIN_PASS = "admin" +BATCH_SIZE = 100 + + +def api(method, path, data=None, token=None): + url = KC_URL + path + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + if data is not None and method != "GET": + headers["Content-Type"] = "application/json" + body = json.dumps(data).encode() + else: + body = None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + resp = urllib.request.urlopen(req) + content = resp.read().decode() + return resp.status, json.loads(content) if content else None + except urllib.error.HTTPError as e: + content = e.read().decode() + try: + return e.code, json.loads(content) + except Exception: + return e.code, content + + +def get_token(): + data = urllib.parse.urlencode({ + "username": ADMIN_USER, "password": ADMIN_PASS, + "grant_type": "password", "client_id": "admin-cli" + }).encode() + req = urllib.request.Request( + KC_URL + f"/realms/master/protocol/openid-connect/token", + data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp = urllib.request.urlopen(req) + return json.loads(resp.read().decode())["access_token"] + + +def main(): + print(f"Keycloak: {KC_URL}") + print(f"Realm: {REALM}") + print() + + token = get_token() + total_deleted = 0 + + while True: + code, users = api("GET", f"/admin/realms/{REALM}/users?max={BATCH_SIZE}", token=token) + if code != 200 or not users: + break + + print(f"Fetched {len(users)} users...") + for u in users: + username = u["username"] + uid = u["id"] + code, _ = api("DELETE", f"/admin/realms/{REALM}/users/{uid}", token=token) + if code == 204: + total_deleted += 1 + else: + print(f" Failed to delete {username}: {code}") + + print(f" Deleted batch. Total so far: {total_deleted}") + + # Refresh token periodically + if total_deleted % 500 == 0: + token = get_token() + + print(f"\nDone. Deleted {total_deleted} users from {REALM}.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/keycloak-ydb-extension/load-test/prepare.sh b/keycloak-ydb-extension/load-test/prepare.sh new file mode 100755 index 00000000..9df70063 --- /dev/null +++ b/keycloak-ydb-extension/load-test/prepare.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Downloads keycloak-benchmark from GitHub releases and extracts JARs into lib/. +# +# Usage: ./prepare.sh [version] +# version defaults to 999.0.0-SNAPSHOT +# +# Examples: +# ./prepare.sh +# ./prepare.sh 26.4.0-SNAPSHOT +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" +VERSION="${1:-999.0.0-SNAPSHOT}" +ARCHIVE="keycloak-benchmark-${VERSION}.tar.gz" +URL="https://github.com/keycloak/keycloak-benchmark/releases/download/${VERSION}/${ARCHIVE}" + +mkdir -p "$LIB_DIR" + +echo "Downloading keycloak-benchmark ${VERSION} ..." +echo " URL: $URL" + +TMP_DIR=$(mktemp -d) +trap "rm -rf $TMP_DIR" EXIT + +curl -fSL --progress-bar -o "$TMP_DIR/$ARCHIVE" "$URL" + +echo "Extracting ..." +tar -xzf "$TMP_DIR/$ARCHIVE" -C "$TMP_DIR" + +DIST_DIR="$TMP_DIR/keycloak-benchmark-${VERSION}" +if [ ! -d "$DIST_DIR/lib" ]; then + echo "ERROR: Unexpected archive structure, lib/ not found in $DIST_DIR" + ls "$TMP_DIR" + exit 1 +fi + +rm -f "$LIB_DIR"/*.jar +cp "$DIST_DIR/lib/"*.jar "$LIB_DIR/" + +echo +echo "Done. JARs in $LIB_DIR/:" +ls -lh "$LIB_DIR/" diff --git a/keycloak-ydb-extension/load-test/setup-test-realm.py b/keycloak-ydb-extension/load-test/setup-test-realm.py new file mode 100644 index 00000000..6b4dbd9a --- /dev/null +++ b/keycloak-ydb-extension/load-test/setup-test-realm.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Sets up a test realm in Keycloak for load testing. + +Creates: + - test-realm with registration enabled + - Clients: gatling (service account), client-0 (OIDC), test-client (public) + - Roles: user, admin, manager + - Groups: developers, testers, devops + - 10 test users (testuser1..10) with passwords, roles, and groups + - 1 benchmark user (user-0) for keycloak-benchmark scenarios + +Usage: + python3 setup-test-realm.py [KC_URL] + KC_URL defaults to http://localhost:9090 +""" + +import sys +import urllib.request +import urllib.parse +import urllib.error +import json + +KC_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:9090" +ADMIN_USER = "admin" +ADMIN_PASS = "admin" + + +def api(method, path, data=None, token=None): + url = KC_URL + path + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + if data is not None and method != "GET": + headers["Content-Type"] = "application/json" + body = json.dumps(data).encode() + else: + body = None + + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + resp = urllib.request.urlopen(req) + content = resp.read().decode() + return resp.status, json.loads(content) if content else None + except urllib.error.HTTPError as e: + content = e.read().decode() + try: + return e.code, json.loads(content) + except Exception: + return e.code, content + + +def get_token(): + data = urllib.parse.urlencode({ + "username": ADMIN_USER, "password": ADMIN_PASS, + "grant_type": "password", "client_id": "admin-cli" + }).encode() + req = urllib.request.Request( + KC_URL + "/realms/master/protocol/openid-connect/token", + data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp = urllib.request.urlopen(req) + return json.loads(resp.read().decode())["access_token"] + + +def ok(status): + return 200 <= status < 300 + + +def main(): + print(f"Keycloak URL: {KC_URL}") + print(f"Admin credentials: {ADMIN_USER}/{ADMIN_PASS}") + print() + + # 1. Token + print("=== Getting admin token ===") + token = get_token() + print(f" Token: {token[:30]}...") + print() + + # 2. Realm + print("=== Creating realm: test-realm ===") + code, resp = api("POST", "/admin/realms", data={ + "realm": "test-realm", + "enabled": True, + "displayName": "Test Realm for Load Testing", + "registrationAllowed": True, + "loginWithEmailAllowed": True + }, token=token) + if code == 201: + print(" CREATED (201)") + elif code == 409: + print(" Already exists (409), skipping") + else: + print(f" FAILED ({code}): {resp}") + sys.exit(1) + print() + + # 3. Clients + print("=== Creating clients ===") + clients_spec = [ + { + "clientId": "gatling", "enabled": True, + "clientAuthenticatorType": "client-secret", + "secret": "setup-for-benchmark", + "redirectUris": ["*"], "serviceAccountsEnabled": True, + "publicClient": False, "protocol": "openid-connect", + "attributes": {"post.logout.redirect.uris": "+"} + }, + { + "clientId": "client-0", "enabled": True, + "clientAuthenticatorType": "client-secret", + "secret": "client-0-secret", + "redirectUris": ["*"], "serviceAccountsEnabled": True, + "publicClient": False, "protocol": "openid-connect", + "attributes": {"post.logout.redirect.uris": "+"} + }, + { + "clientId": "test-client", "enabled": True, + "directAccessGrantsEnabled": True, + "publicClient": True, + "redirectUris": ["*"], "webOrigins": ["*"] + }, + ] + for c in clients_spec: + code, _ = api("POST", "/admin/realms/test-realm/clients", data=c, token=token) + if code == 409: + # Client exists — update its secret and settings + _, existing = api("GET", f"/admin/realms/test-realm/clients?clientId={c['clientId']}", token=token) + if existing: + client_uuid = existing[0]["id"] + update_code, _ = api("PUT", f"/admin/realms/test-realm/clients/{client_uuid}", data={**existing[0], **c}, token=token) + status_msg = "UPDATED" if update_code == 204 else f"update FAILED ({update_code})" + else: + status_msg = "already exists (could not update)" + elif code == 201: + status_msg = "CREATED" + else: + status_msg = f"FAILED ({code})" + print(f" {c['clientId']}: {status_msg}") + + # Assign realm-management roles to gatling service account + print(" Assigning realm-management roles to gatling service account...") + code, clients = api("GET", "/admin/realms/test-realm/clients?clientId=gatling", token=token) + gatling_id = clients[0]["id"] + code, sa_user = api("GET", f"/admin/realms/test-realm/clients/{gatling_id}/service-account-user", token=token) + sa_user_id = sa_user["id"] + code, rm_clients = api("GET", "/admin/realms/test-realm/clients?clientId=realm-management", token=token) + rm_id = rm_clients[0]["id"] + + roles_to_assign = [] + for role_name in ["manage-clients", "view-users", "manage-realm", "manage-users", "query-users", "query-groups"]: + code, role = api("GET", f"/admin/realms/test-realm/clients/{rm_id}/roles/{role_name}", token=token) + roles_to_assign.append(role) + + code, _ = api("POST", + f"/admin/realms/test-realm/users/{sa_user_id}/role-mappings/clients/{rm_id}", + data=roles_to_assign, token=token) + print(f" Roles assigned: {code}") + print() + + # 4. Realm roles + print("=== Creating realm roles ===") + for role_name in ["user", "admin", "manager"]: + code, _ = api("POST", "/admin/realms/test-realm/roles", + data={"name": role_name, "description": f"{role_name} role"}, token=token) + status_msg = "CREATED" if code == 201 else "already exists" if code == 409 else f"FAILED ({code})" + print(f" {role_name}: {status_msg}") + print() + + # 5. Groups + print("=== Creating groups ===") + for group_name in ["developers", "testers", "devops"]: + code, _ = api("POST", "/admin/realms/test-realm/groups", + data={"name": group_name}, token=token) + status_msg = "CREATED" if code == 201 else "already exists" if code == 409 else f"FAILED ({code})" + print(f" {group_name}: {status_msg}") + print() + + # Fetch roles and groups for assignment + roles = {} + for rname in ["user", "admin", "manager"]: + code, data = api("GET", f"/admin/realms/test-realm/roles/{rname}", token=token) + roles[rname] = data + + code, groups_data = api("GET", "/admin/realms/test-realm/groups", token=token) + groups = {g["name"]: g["id"] for g in groups_data} + + role_list = ["user", "admin", "manager"] + group_list = ["developers", "testers", "devops"] + + # 6. Test users + print("=== Creating test users ===") + for i in range(1, 11): + uname = f"testuser{i}" + role_name = role_list[(i - 1) % 3] + group_name = group_list[(i - 1) % 3] + + code, _ = api("POST", "/admin/realms/test-realm/users", data={ + "username": uname, + "email": f"{uname}@example.com", + "firstName": "Test", + "lastName": f"User{i}", + "enabled": True, + "credentials": [{"type": "password", "value": "password", "temporary": False}] + }, token=token) + + if code == 201: + code2, users = api("GET", + f"/admin/realms/test-realm/users?username={uname}&exact=true", token=token) + user_id = users[0]["id"] + + api("POST", f"/admin/realms/test-realm/users/{user_id}/role-mappings/realm", + data=[roles[role_name]], token=token) + api("PUT", f"/admin/realms/test-realm/users/{user_id}/groups/{groups[group_name]}", + data={}, token=token) + + print(f" {uname}: CREATED | role={role_name} | group={group_name}") + elif code == 409: + print(f" {uname}: already exists, skipping") + else: + print(f" {uname}: FAILED ({code})") + + # 7. Benchmark user (user-0) + print() + print("=== Creating benchmark user (user-0) ===") + code, _ = api("POST", "/admin/realms/test-realm/users", data={ + "username": "user-0", + "enabled": True, + "firstName": "Firstname", + "lastName": "Lastname", + "email": "user-0@keycloak.org", + "credentials": [{"type": "password", "value": "user-0-password", "temporary": False}] + }, token=token) + status_msg = "CREATED" if code == 201 else "already exists" if code == 409 else f"FAILED ({code})" + print(f" user-0: {status_msg}") + print() + + # 8. Verification + print("=== Verification ===") + + # Users + code, users = api("GET", "/admin/realms/test-realm/users?max=50", token=token) + print(f" Total users in test-realm: {len(users)}") + for u in users: + code, rm = api("GET", + f"/admin/realms/test-realm/users/{u['id']}/role-mappings/realm", token=token) + user_roles = [r["name"] for r in (rm or []) if not r["name"].startswith("default-roles")] + code, ug = api("GET", + f"/admin/realms/test-realm/users/{u['id']}/groups", token=token) + user_groups = [g["name"] for g in (ug or [])] + print(f" {u['username']:15} | {u.get('email',''):25} | roles={user_roles} | groups={user_groups}") + + # Clients + code, all_clients = api("GET", "/admin/realms/test-realm/clients", token=token) + custom_clients = [c for c in all_clients if c["clientId"] in ("gatling", "client-0", "test-client")] + print(f"\n Custom clients: {[c['clientId'] for c in custom_clients]}") + + # Test login + print("\n=== Testing login ===") + try: + login_data = urllib.parse.urlencode({ + "username": "testuser1", "password": "password", + "grant_type": "password", "client_id": "test-client" + }).encode() + req = urllib.request.Request( + KC_URL + "/realms/test-realm/protocol/openid-connect/token", + data=login_data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp = urllib.request.urlopen(req) + data = json.loads(resp.read().decode()) + print(f" testuser1 login: SUCCESS (token expires in {data['expires_in']}s)") + except urllib.error.HTTPError as e: + print(f" testuser1 login: FAILED ({e.code})") + + print() + print("Setup complete!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml new file mode 100644 index 00000000..14a6409b --- /dev/null +++ b/keycloak-ydb-extension/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + tech.ydb + keycloak-ydb-extension-parent + 1.0-SNAPSHOT + pom + + keycloak-ydb-extension-parent + Keycloak extension storing data in YDB + + + core + test + retry-proxy + + + + 21 + ${java.version} + ${java.version} + ${java.version} + + UTF-8 + + 2.2.21 + official + 21 + + 26.4.7 + + 2.3.20 + 1.1.1 + + 7.0.2 + 0.9.2 + + 3.4.1 + 1.5.16 + + + + + + org.keycloak + keycloak-parent + ${keycloak.version} + pom + import + + + org.junit + junit-bom + 5.14.3 + pom + import + + + tech.ydb + keycloak-ydb-extension + ${project.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + maven-failsafe-plugin + 3.5.4 + + + + + diff --git a/keycloak-ydb-extension/retry-proxy/Dockerfile b/keycloak-ydb-extension/retry-proxy/Dockerfile new file mode 100644 index 00000000..2b38ec93 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/Dockerfile @@ -0,0 +1,10 @@ +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY target/keycloak-ydb-retry-proxy-1.0-SNAPSHOT.jar retry-proxy.jar +ENV TARGET_URL=http://keycloak:8080 +ENV MAX_RETRIES=10 +ENV BASE_DELAY_MS=50 +ENV MAX_DELAY_MS=2000 +ENV LISTEN_PORT=8080 +EXPOSE 8080 +CMD ["java", "-jar", "retry-proxy.jar"] diff --git a/keycloak-ydb-extension/retry-proxy/README.md b/keycloak-ydb-extension/retry-proxy/README.md new file mode 100644 index 00000000..cfbf3757 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -0,0 +1,51 @@ +# YDB Retry Proxy + +HTTP reverse proxy for Keycloak that automatically retries requests on YDB-specific retryable errors. + +## Overview + +The proxy sits between the client and Keycloak, intercepting all HTTP traffic. When the backend returns a `503` response +with a body containing `ydb_retryable`, the proxy transparently retries the request using exponential backoff with +jitter. + +Key behaviors: + +- Retries only on `503` responses containing `ydb_retryable` in the body +- Exponential backoff with jitter: `baseDelay * 2^attempt`, capped at `maxDelay` +- Stops retrying if the client disconnects +- Returns `502 Bad Gateway` on connection errors (timeout, DNS, IOException) without retrying +- Filters hop-by-hop headers (RFC 2616 Section 13.5.1) +- Rewrites `Location` headers from the internal target address to the proxy address + +## Configuration + +All parameters are set via environment variables. + +### Proxy + +| Variable | Default | Description | +|-----------------|-------------------------|-----------------------------------------------| +| `TARGET_URL` | `http://localhost:8080` | URL of the target server (Keycloak) | +| `LISTEN_PORT` | `8080` | Port the proxy listens on | +| `MAX_RETRIES` | `10` | Maximum number of retries on retryable errors | +| `BASE_DELAY_MS` | `50` | Base delay before the first retry (ms) | +| `MAX_DELAY_MS` | `2000` | Maximum delay between retries (ms) | + +### Keycloak configuration + +When Keycloak is deployed behind this proxy, its `hostname` setting must match the public address the proxy is exposed on — not `localhost`. Otherwise Keycloak generates login form URLs pointing to a different origin than the client connected to, which breaks the session cookie flow (`cookie_not_found`). + +Example (`keycloak.conf`): + +```properties +proxy-headers=forwarded +hostname=http://0.0.0.0:9090 # must match the address clients use to reach the proxy +``` + +### HTTP Client + +| Variable | Default | Description | +|-----------------------------|---------|------------------------------------------| +| `CLIENT_MAX_CONNECTIONS` | `1000` | Maximum number of concurrent connections | +| `CLIENT_CONNECT_TIMEOUT_MS` | `10000` | Connection establishment timeout (ms) | +| `CLIENT_REQUEST_TIMEOUT_MS` | `30000` | Response wait timeout (ms) | diff --git a/keycloak-ydb-extension/retry-proxy/pom.xml b/keycloak-ydb-extension/retry-proxy/pom.xml new file mode 100644 index 00000000..8b695e29 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/pom.xml @@ -0,0 +1,175 @@ + + + 4.0.0 + + + tech.ydb + keycloak-ydb-extension-parent + 1.0-SNAPSHOT + ../pom.xml + + + jar + keycloak-ydb-extension (retry-proxy) + keycloak-ydb-retry-proxy + 1.0-SNAPSHOT + + + src/main/kotlin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + + default-compile + none + + + default-testCompile + none + + + java-compile + + compile + + compile + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + + + + + + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + tech.ydb.keycloak.proxy.RetryProxyKt + + + + + + + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + io.ktor + ktor-server-core-jvm + ${ktor.version} + + + io.ktor + ktor-server-netty-jvm + ${ktor.version} + + + io.ktor + ktor-server-forwarded-header-jvm + ${ktor.version} + + + + + io.ktor + ktor-client-core-jvm + ${ktor.version} + + + io.ktor + ktor-client-cio-jvm + ${ktor.version} + + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + io.ktor + ktor-server-test-host-jvm + ${ktor.version} + test + + + io.ktor + ktor-client-mock-jvm + ${ktor.version} + test + + + org.junit.jupiter + junit-jupiter + 5.14.3 + test + + + io.mockk + mockk-jvm + 1.13.16 + test + + + org.jetbrains.kotlinx + kotlinx-coroutines-test + 1.10.2 + test + + + diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt new file mode 100644 index 00000000..31a3ac8f --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt @@ -0,0 +1,14 @@ +package tech.ydb.keycloak.proxy + +import io.ktor.client.* +import tech.ydb.keycloak.proxy.client.createProxyClient +import tech.ydb.keycloak.proxy.config.ProxyConfig +import tech.ydb.keycloak.proxy.controller.ProxyController +import tech.ydb.keycloak.proxy.service.ProxyService + +class Dependencies( + val config: ProxyConfig = ProxyConfig.fromEnv(), + val client: HttpClient = createProxyClient(config.client), + val proxyService: ProxyService = ProxyService(client, config), + val controller: ProxyController = ProxyController(proxyService, config), +) diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt new file mode 100644 index 00000000..9a3cb593 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt @@ -0,0 +1,19 @@ +package tech.ydb.keycloak.proxy + +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import org.slf4j.LoggerFactory +import tech.ydb.keycloak.proxy.plugins.configureRouting + +private val log = LoggerFactory.getLogger("RetryProxy") + +fun main() { + val deps = Dependencies() + val config = deps.config + + log.info("Starting retry proxy: listen=:${config.listenPort} target=${config.targetUrl} maxRetries=${config.maxRetries} baseDelay=${config.baseDelayMs}ms maxDelay=${config.maxDelayMs}ms") + + embeddedServer(Netty, port = config.listenPort) { + configureRouting(deps) + }.start(wait = true) +} diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt new file mode 100644 index 00000000..a7b6ec57 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt @@ -0,0 +1,17 @@ +package tech.ydb.keycloak.proxy.client + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import tech.ydb.keycloak.proxy.config.ClientConfig + +fun createProxyClient(config: ClientConfig): HttpClient = HttpClient(CIO) { + engine { + maxConnectionsCount = config.maxConnectionsCount + endpoint { + connectTimeout = config.connectTimeoutMs + requestTimeout = config.requestTimeoutMs + } + } + expectSuccess = false + followRedirects = false +} diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt new file mode 100644 index 00000000..e19fd3df --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt @@ -0,0 +1,35 @@ +package tech.ydb.keycloak.proxy.config + +data class ClientConfig( + val maxConnectionsCount: Int, + val connectTimeoutMs: Long, + val requestTimeoutMs: Long, +) { + companion object { + fun fromEnv(): ClientConfig = ClientConfig( + maxConnectionsCount = (System.getenv("CLIENT_MAX_CONNECTIONS") ?: "1000").toInt(), + connectTimeoutMs = (System.getenv("CLIENT_CONNECT_TIMEOUT_MS") ?: "10000").toLong(), + requestTimeoutMs = (System.getenv("CLIENT_REQUEST_TIMEOUT_MS") ?: "30000").toLong(), + ) + } +} + +data class ProxyConfig( + val targetUrl: String, + val maxRetries: Int, + val baseDelayMs: Long, + val maxDelayMs: Long, + val listenPort: Int, + val client: ClientConfig, +) { + companion object { + fun fromEnv(): ProxyConfig = ProxyConfig( + targetUrl = System.getenv("TARGET_URL") ?: "http://localhost:8080", + maxRetries = (System.getenv("MAX_RETRIES") ?: "10").toInt(), + baseDelayMs = (System.getenv("BASE_DELAY_MS") ?: "50").toLong(), + maxDelayMs = (System.getenv("MAX_DELAY_MS") ?: "2000").toLong(), + listenPort = (System.getenv("LISTEN_PORT") ?: "8080").toInt(), + client = ClientConfig.fromEnv(), + ) + } +} diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt new file mode 100644 index 00000000..46e707a6 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt @@ -0,0 +1,73 @@ +package tech.ydb.keycloak.proxy.controller + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.origin +import io.ktor.server.request.* +import io.ktor.server.response.* +import tech.ydb.keycloak.proxy.config.ProxyConfig +import tech.ydb.keycloak.proxy.service.ProxyResult.* +import tech.ydb.keycloak.proxy.service.ProxyService +import tech.ydb.keycloak.proxy.utils.isHeader +import tech.ydb.keycloak.proxy.utils.isHopByHop + +class ProxyController( + private val proxyService: ProxyService, + private val config: ProxyConfig, +) { + suspend fun handle(call: ApplicationCall) { + val method = call.request.httpMethod + val path = call.request.uri + val body = call.receive() + val contentType = call.request.contentType() + val host = call.request.headers["Host"] ?: call.request.host() + val remoteHost = call.request.origin.remoteHost + val scheme = call.request.origin.scheme + + val result = proxyService.proxyRequest( + method = method, + path = path, + body = body, + contentType = contentType, + headers = call.request.headers, + host = host, + remoteHost = remoteHost, + scheme = scheme + ) + + when (result) { + is Success -> call.handleSuccess(result) + + is Error -> call.respondText(result.message, status = HttpStatusCode.BadGateway) + + is ClientDisconnected -> {} + } + } + + private suspend fun ApplicationCall.handleSuccess(result: Success) { + val originalHost = request.headers["Host"] ?: "localhost:${config.listenPort}" + val scheme = request.origin.scheme + + result.headers.forEach { name, values -> + if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) { + values.forEach { value -> + response.header(name, rewriteInternalUrl(name, value, originalHost, scheme)) + } + } + } + + respondBytes(result.body, result.contentType, result.status) + } + + private fun rewriteInternalUrl( + name: String, + value: String, + originalHost: String, + scheme: String, + ): String { + if (isHeader(name, HttpHeaders.Location)) { + return value.replace(config.targetUrl, "$scheme://$originalHost") + } + return value + } +} diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt new file mode 100644 index 00000000..6b7571cb --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt @@ -0,0 +1,18 @@ +package tech.ydb.keycloak.proxy.plugins + +import io.ktor.server.application.* +import io.ktor.server.plugins.forwardedheaders.ForwardedHeaders +import io.ktor.server.routing.* +import tech.ydb.keycloak.proxy.Dependencies + +fun Application.configureRouting(deps: Dependencies) { + install(ForwardedHeaders) + + routing { + route("{...}") { + handle { + deps.controller.handle(call) + } + } + } +} diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt new file mode 100644 index 00000000..0024a45d --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt @@ -0,0 +1,120 @@ +package tech.ydb.keycloak.proxy.service + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.http.HttpHeaders.Forwarded +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import org.slf4j.LoggerFactory +import tech.ydb.keycloak.proxy.config.ProxyConfig +import tech.ydb.keycloak.proxy.service.ProxyResult.* +import tech.ydb.keycloak.proxy.utils.YdbRetryableBody +import tech.ydb.keycloak.proxy.utils.isHeader +import tech.ydb.keycloak.proxy.utils.isHopByHop +import kotlin.coroutines.coroutineContext +import kotlin.random.Random +import io.ktor.http.content.ByteArrayContent as OutgoingByteArrayContent + +class ProxyService( + private val client: HttpClient, + private val config: ProxyConfig, +) { + private val log = LoggerFactory.getLogger(ProxyService::class.java) + + suspend fun proxyRequest( + method: HttpMethod, + path: String, + body: ByteArray, + contentType: ContentType, + headers: Headers, + host: String, + remoteHost: String, + scheme: String, + ): ProxyResult { + for (attempt in 0..config.maxRetries) { + if (!coroutineContext.isActive) { + log.info("Client disconnected, stopping retries for $method $path (attempt $attempt)") + return ClientDisconnected + } + + val response = try { + forwardToTarget(method, path, body, contentType, headers, host, remoteHost, scheme) + } catch (e: Exception) { + return Error("Proxy error: ${e.message}") + } + + val responseBody = response.readRawBytes() + + if (YdbRetryableBody.isRetryable503(response.status, responseBody)) { + if (retryWithBackoff(attempt, method, path)) continue + } else { + return Success(responseBody, response.headers, response.contentType(), response.status) + } + } + + return Error("All ${config.maxRetries} retries exhausted for $method $path") + } + + private suspend fun retryWithBackoff(attempt: Int, method: HttpMethod, path: String): Boolean { + if (attempt >= config.maxRetries) { + log.warn("YDB retryable 503 on $method $path, all ${config.maxRetries} retries exhausted") + return false + } + + backoffDelay(attempt).let { delayMs -> + log.warn("YDB retryable 503 on $method $path (attempt ${attempt + 1}/${config.maxRetries}), retrying in ${delayMs}ms") + delay(delayMs) + } + + return true + } + + private suspend fun forwardToTarget( + method: HttpMethod, + path: String, + body: ByteArray, + contentType: ContentType, + headers: Headers, + host: String, + remoteHost: String, + scheme: String, + ): HttpResponse = client.request("${config.targetUrl}$path") { + this.method = method + + copyHeaders(headers) + header(Forwarded, "for=$remoteHost;host=$host;proto=$scheme") + + setBody(OutgoingByteArrayContent(body, contentType)) + } + + private fun HttpRequestBuilder.copyHeaders(headers: Headers) { + headers.forEach { name, values -> + if (!isHopByHop(name) + && !isHeader(name, HttpHeaders.Host) + && !isHeader(name, HttpHeaders.ContentType) + && !isHeader(name, HttpHeaders.ContentLength) + ) { + values.forEach { header(name, it) } + } + } + } + + fun backoffDelay(attempt: Int): Long { + val exponential = (config.baseDelayMs shl attempt).coerceAtMost(config.maxDelayMs) + return Random.nextLong(0, exponential + 1) + } +} + +sealed class ProxyResult { + class Success( + val body: ByteArray, + val headers: Headers, + val contentType: ContentType?, + val status: HttpStatusCode, + ) : ProxyResult() + + data class Error(val message: String) : ProxyResult() + data object ClientDisconnected : ProxyResult() +} diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt new file mode 100644 index 00000000..1f59cae1 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt @@ -0,0 +1,19 @@ +package tech.ydb.keycloak.proxy.utils + +import io.ktor.http.* + +// https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 +val HOP_BY_HOP_HEADERS = listOf( + HttpHeaders.Connection, + "Keep-Alive", + HttpHeaders.ProxyAuthenticate, + HttpHeaders.ProxyAuthorization, + HttpHeaders.TE, + HttpHeaders.Trailer, + HttpHeaders.TransferEncoding, + HttpHeaders.Upgrade, +) + +fun isHopByHop(name: String): Boolean = HOP_BY_HOP_HEADERS.any { it.equals(name, ignoreCase = true) } + +fun isHeader(name: String, header: String): Boolean = name.equals(header, ignoreCase = true) diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt new file mode 100644 index 00000000..14ccd975 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt @@ -0,0 +1,10 @@ +package tech.ydb.keycloak.proxy.utils + +import io.ktor.http.HttpStatusCode + +object YdbRetryableBody { + private const val ERROR_CODE = "ydb_retryable" + + fun isRetryable503(status: HttpStatusCode, body: ByteArray): Boolean = + status == HttpStatusCode.ServiceUnavailable && body.decodeToString().contains(ERROR_CODE) +} diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt new file mode 100644 index 00000000..c8e3663e --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt @@ -0,0 +1,147 @@ +package tech.ydb.keycloak.proxy.controller + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import tech.ydb.keycloak.proxy.Dependencies +import tech.ydb.keycloak.proxy.config.ClientConfig +import tech.ydb.keycloak.proxy.config.ProxyConfig +import tech.ydb.keycloak.proxy.plugins.configureRouting +import tech.ydb.keycloak.proxy.service.ProxyResult.* +import tech.ydb.keycloak.proxy.service.ProxyService + +class ProxyControllerTest { + + private val config = ProxyConfig( + targetUrl = "http://backend:8080", + maxRetries = 3, + baseDelayMs = 10, + maxDelayMs = 100, + listenPort = 9090, + client = ClientConfig( + maxConnectionsCount = 10, + connectTimeoutMs = 1000, + requestTimeoutMs = 5000, + ), + ) + + private val proxyService = mockk() + + private fun withProxy(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + configureRouting( + Dependencies( + config = config, + client = mockk(), + proxyService = proxyService, + controller = ProxyController(proxyService, config), + ) + ) + } + + block() + } + + @Test + fun forwardsSuccessResponse() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns + Success( + body = "hello".toByteArray(), + headers = headersOf(), + contentType = ContentType.Text.Plain, + status = HttpStatusCode.OK, + ) + + client.get("/test").let { + assertEquals(HttpStatusCode.OK, it.status) + assertEquals("hello", it.bodyAsText()) + } + } + + @Test + fun returnsErrorAsBadGateway() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns + Error("connection refused") + + client.get("/fail").let { + assertEquals(HttpStatusCode.BadGateway, it.status) + assertEquals("connection refused", it.bodyAsText()) + } + } + + @Test + fun filtersHopByHopHeaders() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns + Success( + body = "ok".toByteArray(), + headers = headersOf( + HttpHeaders.Connection to listOf("keep-alive"), + HttpHeaders.TransferEncoding to listOf("chunked"), + HttpHeaders.ContentLength to listOf("999"), + "X-Custom" to listOf("value"), + ), + contentType = ContentType.Text.Plain, + status = HttpStatusCode.OK, + ) + + client.get("/headers").let { + assertEquals("value", it.headers["X-Custom"]) + assertNull(it.headers[HttpHeaders.Connection]) + assertNull(it.headers[HttpHeaders.TransferEncoding]) + // Backend's Content-Length (999) must not leak — Ktor sets its own based on actual body size + assertEquals("2", it.headers[HttpHeaders.ContentLength]) + } + } + + @Test + fun rewritesLocationHeader() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns + Success( + body = ByteArray(0), + headers = headersOf(HttpHeaders.Location, "http://backend:8080/realms/master"), + contentType = null, + status = HttpStatusCode.Found, + ) + + createClient { followRedirects = false }.get("/login") { + header(HttpHeaders.Host, "localhost:9090") + }.let { + assertEquals(HttpStatusCode.Found, it.status) + assertEquals("http://localhost:9090/realms/master", it.headers[HttpHeaders.Location]) + } + } + + @Test + fun rewritesLocationHeaderWithHttpsFromForwarded() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns + Success( + body = ByteArray(0), + headers = headersOf(HttpHeaders.Location, "http://backend:8080/realms/master"), + contentType = null, + status = HttpStatusCode.Found, + ) + + createClient { followRedirects = false }.get("/login") { + header(HttpHeaders.Forwarded, "for=1.2.3.4;host=client.example.com;proto=https") + header(HttpHeaders.Host, "client.example.com") + }.let { + assertEquals(HttpStatusCode.Found, it.status) + assertEquals("https://client.example.com/realms/master", it.headers[HttpHeaders.Location]) + } + } + + @Test + fun clientDisconnectedReturnsNothing() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns + ClientDisconnected + + client.get("/disconnect").let { + assertEquals("", it.bodyAsText()) + } + } +} diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt new file mode 100644 index 00000000..9552cb94 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt @@ -0,0 +1,227 @@ +package tech.ydb.keycloak.proxy.service + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import tech.ydb.keycloak.proxy.config.ProxyConfig + +class ProxyServiceTest { + + private val config = mockk { + every { targetUrl } returns "http://backend:8080" + every { maxRetries } returns 3 + every { baseDelayMs } returns 1L + every { maxDelayMs } returns 10L + every { listenPort } returns 9090 + } + + private fun mockClient(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): HttpClient { + return HttpClient(MockEngine) { + engine { addHandler(handler) } + expectSuccess = false + followRedirects = false + } + } + + private fun proxyService(client: HttpClient) = ProxyService(client, config) + + private suspend fun doRequest(service: ProxyService): ProxyResult = service.proxyRequest( + method = HttpMethod.Get, + path = "/test", + body = ByteArray(0), + contentType = ContentType.Application.Json, + headers = headersOf(), + host = "localhost:9090", + remoteHost = "127.0.0.1", + scheme = "http", + ) + + @Test + fun forwardsSuccessfulResponse() = runTest { + val service = proxyService(mockClient { + respond("ok", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "text/plain")) + }) + + doRequest(service).let { + assertInstanceOf(ProxyResult.Success::class.java, it) + it as ProxyResult.Success + assertEquals(HttpStatusCode.OK, it.status) + assertEquals("ok", String(it.body)) + } + } + + @Test + fun returnsErrorOnException() = runTest { + val service = proxyService(mockClient { + throw RuntimeException("connection refused") + }) + + doRequest(service).let { + assertInstanceOf(ProxyResult.Error::class.java, it) + it as ProxyResult.Error + assertEquals("Proxy error: connection refused", it.message) + } + } + + @Test + fun doesNotRetryOnException() = runTest { + var requestCount = 0 + val service = proxyService(mockClient { + requestCount++ + throw RuntimeException("fail") + }) + + doRequest(service) + assertEquals(1, requestCount) + } + + @Test + fun retriesOnYdbRetryable503() = runTest { + var requestCount = 0 + val service = proxyService(mockClient { + requestCount++ + if (requestCount <= 2) { + respond("""{"error": "ydb_retryable"}""", HttpStatusCode.ServiceUnavailable) + } else { + respond("ok", HttpStatusCode.OK) + } + }) + + doRequest(service).let { + assertInstanceOf(ProxyResult.Success::class.java, it) + it as ProxyResult.Success + assertEquals(HttpStatusCode.OK, it.status) + assertEquals("ok", String(it.body)) + } + assertEquals(3, requestCount) + } + + @Test + fun returnsErrorWhenRetriesExhausted() = runTest { + every { config.maxRetries } returns 2 + var requestCount = 0 + val service = proxyService(mockClient { + requestCount++ + respond("""{"error": "ydb_retryable"}""", HttpStatusCode.ServiceUnavailable) + }) + + doRequest(service).let { + assertInstanceOf(ProxyResult.Error::class.java, it) + it as ProxyResult.Error + assertTrue(it.message.contains("retries exhausted")) + } + // 1 initial + 2 retries = 3 + assertEquals(3, requestCount) + + every { config.maxRetries } returns 3 + } + + @Test + fun doesNotRetryOnNonRetryable503() = runTest { + var requestCount = 0 + val service = proxyService(mockClient { + requestCount++ + respond("service unavailable", HttpStatusCode.ServiceUnavailable) + }) + + doRequest(service).let { + assertInstanceOf(ProxyResult.Success::class.java, it) + it as ProxyResult.Success + assertEquals(HttpStatusCode.ServiceUnavailable, it.status) + } + assertEquals(1, requestCount) + } + + @Test + fun forwardsNon503ErrorStatusAsIs() = runTest { + val service = proxyService(mockClient { + respond("not found", HttpStatusCode.NotFound) + }) + + doRequest(service).let { + assertInstanceOf(ProxyResult.Success::class.java, it) + it as ProxyResult.Success + assertEquals(HttpStatusCode.NotFound, it.status) + assertEquals("not found", String(it.body)) + } + } + + @Test + fun forwardsRequestToCorrectUrl() = runTest { + var capturedUrl: String? = null + val service = proxyService(mockClient { request -> + capturedUrl = request.url.toString() + respond("ok", HttpStatusCode.OK) + }) + + doRequest(service) + assertEquals("http://backend:8080/test", capturedUrl) + } + + @Test + fun setsForwardedHeader() = runTest { + var capturedHeaders: Headers? = null + val service = proxyService(mockClient { request -> + capturedHeaders = request.headers + respond("ok", HttpStatusCode.OK) + }) + + doRequest(service) + assertEquals("for=127.0.0.1;host=localhost:9090;proto=http", capturedHeaders!![HttpHeaders.Forwarded]) + } + + @Test + fun filtersHopByHopAndServiceHeaders() = runTest { + var capturedHeaders: Headers? = null + val service = proxyService(mockClient { request -> + capturedHeaders = request.headers + respond("ok", HttpStatusCode.OK) + }) + + service.proxyRequest( + method = HttpMethod.Get, + path = "/test", + body = ByteArray(0), + contentType = ContentType.Application.Json, + headers = headersOf( + HttpHeaders.Connection to listOf("keep-alive"), + HttpHeaders.Host to listOf("original-host"), + HttpHeaders.ContentType to listOf("application/json"), + HttpHeaders.ContentLength to listOf("0"), + "X-Custom" to listOf("value"), + HttpHeaders.Authorization to listOf("Bearer token"), + ), + host = "localhost:9090", + remoteHost = "127.0.0.1", + scheme = "http", + ) + + assertNull(capturedHeaders!![HttpHeaders.Connection]) + assertNull(capturedHeaders!![HttpHeaders.Host]) + assertNull(capturedHeaders!![HttpHeaders.ContentType]) + assertNull(capturedHeaders!![HttpHeaders.ContentLength]) + assertEquals("value", capturedHeaders!!["X-Custom"]) + assertEquals("Bearer token", capturedHeaders!![HttpHeaders.Authorization]) + } + + @Test + fun backoffDelayIsWithinBounds() { + val service = proxyService(mockClient { respond("ok", HttpStatusCode.OK) }) + + repeat(100) { + val delay = service.backoffDelay(0) + assertTrue(delay in 0..config.baseDelayMs) + } + + repeat(100) { + val delay = service.backoffDelay(5) + assertTrue(delay in 0..config.maxDelayMs) + } + } +} diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt new file mode 100644 index 00000000..437459d2 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt @@ -0,0 +1,50 @@ +package tech.ydb.keycloak.proxy.utils + +import io.ktor.http.* +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class HeaderUtilsTest { + + @Test + fun hopByHopHeadersAreDetected() { + assertTrue(isHopByHop("Connection")) + assertTrue(isHopByHop("Transfer-Encoding")) + assertTrue(isHopByHop("Keep-Alive")) + assertTrue(isHopByHop("Proxy-Authenticate")) + assertTrue(isHopByHop("Proxy-Authorization")) + assertTrue(isHopByHop("TE")) + assertTrue(isHopByHop("Trailer")) + assertTrue(isHopByHop("Upgrade")) + } + + @Test + fun regularHeadersAreNotHopByHop() { + assertFalse(isHopByHop("Content-Type")) + assertFalse(isHopByHop("Authorization")) + assertFalse(isHopByHop("Accept")) + assertFalse(isHopByHop("Location")) + } + + @Test + fun hopByHopIsCaseInsensitive() { + assertTrue(isHopByHop("connection")) + assertTrue(isHopByHop("CONNECTION")) + assertTrue(isHopByHop("transfer-encoding")) + assertTrue(isHopByHop("KEEP-ALIVE")) + } + + @Test + fun isHeaderMatchesCaseInsensitive() { + assertTrue(isHeader("Content-Type", HttpHeaders.ContentType)) + assertTrue(isHeader("content-type", HttpHeaders.ContentType)) + assertTrue(isHeader("CONTENT-TYPE", HttpHeaders.ContentType)) + } + + @Test + fun isHeaderRejectsNonMatching() { + assertFalse(isHeader("Content-Type", HttpHeaders.ContentLength)) + assertFalse(isHeader("Accept", HttpHeaders.Location)) + } +} diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt new file mode 100644 index 00000000..500418f4 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt @@ -0,0 +1,55 @@ +package tech.ydb.keycloak.proxy.utils + +import io.ktor.http.* +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.keycloak.proxy.utils.YdbRetryableBody.isRetryable503 + +class YdbRetryableBodyTest { + + @Test + fun non503Status() { + assertFalse(isRetryable503(HttpStatusCode.OK, "ydb_retryable".toByteArray())) + } + + @Test + fun detectsMarkerInKeycloakJson() { + assertTrue( + isRetryable503( + HttpStatusCode.ServiceUnavailable, + """{"error":"ydb_retryable","error_description":"Transaction aborted, please retry"}""".toByteArray(), + ), + ) + } + + @Test + fun detectsMarkerInPlainText() { + assertTrue(isRetryable503(HttpStatusCode.ServiceUnavailable, "ydb_retryable".toByteArray())) + assertTrue(isRetryable503(HttpStatusCode.ServiceUnavailable, "error=ydb_retryable".toByteArray())) + } + + @Test + fun detectsMarkerAsSubstring() { + assertTrue( + isRetryable503( + HttpStatusCode.ServiceUnavailable, + """{"error":"internal","error_description":"ydb_retryable"}""".toByteArray(), + ), + ) + assertTrue( + isRetryable503(HttpStatusCode.ServiceUnavailable, "ydb_retryable".toByteArray()), + ) + } + + @Test + fun ignoresNonMatching503Body() { + assertFalse(isRetryable503(HttpStatusCode.ServiceUnavailable, "service unavailable".toByteArray())) + assertFalse(isRetryable503(HttpStatusCode.ServiceUnavailable, "".toByteArray())) + } + + @Test + fun handlesNotOnlyStringByteArrays() { + assertFalse(isRetryable503(HttpStatusCode.ServiceUnavailable, byteArrayOf(0x00, 0x01, 0xFF.toByte(), 0xFE.toByte()))) + } +} diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh new file mode 100755 index 00000000..6140d57d --- /dev/null +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +echo "=== Building keycloak-ydb-extension (core) ===" +mvn -f core/pom.xml clean package -q -DskipTests + +JAR_FILE="core/target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" +if [ ! -f "$JAR_FILE" ]; then + echo "Error: $JAR_FILE not found. Build may have failed." + exit 1 +fi + +mkdir -p docker/providers +cp "$JAR_FILE" docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar +echo " JAR copied to docker/providers/" + +echo "" +echo "=== Building retry-proxy ===" +mvn -f retry-proxy/pom.xml package -q -DskipTests +echo " retry-proxy JAR built" + +echo "" +echo "=== Starting Docker Compose (YDB + Keycloak + retry-proxy) ===" +docker compose -f docker/docker-compose.yml up -d --build + +echo "" +echo "Stack is starting. Wait ~30-60s for Keycloak to initialize." +echo "" +echo " Keycloak (via retry-proxy): http://localhost:9090" +echo " YDB Monitoring: http://localhost:8765" +echo " Admin credentials: admin / admin" +echo "" +echo "Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak" diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml new file mode 100644 index 00000000..b6ca97b6 --- /dev/null +++ b/keycloak-ydb-extension/test/pom.xml @@ -0,0 +1,159 @@ + + 4.0.0 + + tech.ydb + keycloak-ydb-extension-parent + 1.0-SNAPSHOT + + keycloak-ydb-extension-test + keycloak-ydb-extension-test + jar + + + + + tech.ydb + keycloak-ydb-extension + + + + + org.keycloak + keycloak-quarkus-server + test + + + org.keycloak + keycloak-crypto-fips1402 + + + + + org.keycloak + keycloak-services + test + + + org.keycloak + keycloak-server-spi + test + + + org.keycloak + keycloak-server-spi-private + test + + + org.keycloak + keycloak-model-jpa + test + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + test + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + test + + + + + junit + junit + test + + + org.hamcrest + hamcrest + test + + + + + log4j + log4j + test + + + org.slf4j + slf4j-api + test + + + org.slf4j + slf4j-reload4j + test + + + + + org.testcontainers + testcontainers + 1.20.6 + test + + + tech.ydb.test + ydb-junit4-support + 2.3.27 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + test-compile + test-compile + + testCompile + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/java + + + + + + + + maven-surefire-plugin + + -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED + + + Ydb,Infinispan + file:${project.build.directory}/test-classes/log4j.properties + org.jboss.logmanager.LogManager + log4j + + + + + + diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java new file mode 100644 index 00000000..b05fc50e --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java @@ -0,0 +1,218 @@ +package tech.ydb.keycloak; + +import jakarta.persistence.EntityManager; +import org.jboss.logging.Logger; +import org.junit.Test; +import org.keycloak.common.Version; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.migration.MigrationModel; +import org.keycloak.migration.ModelVersion; +import org.keycloak.models.*; +import org.keycloak.models.jpa.entities.MigrationModelEntity; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.List; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +@RequireProvider(value=RealmProvider.class, only="ydb") +@RequireProvider(value=ClientProvider.class, only="ydb") +@RequireProvider(value=ClientScopeProvider.class, only="ydb") +public class MigrationModelTest extends KeycloakModelTest { + + private static final Logger logger = Logger.getLogger(MigrationModelTest.class); + + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void multipleEntities() { + inComittedTransaction(1, (session , i) -> { + String currentVersion = new ModelVersion(Version.VERSION).toString(); + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + List entities = getMigrationEntities(em); + assertThat(entities.size(), is(1)); + assertMigrationModelEntity(entities.get(0), currentVersion); + + MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel(); + assertThat(m.getStoredVersion(), is(currentVersion)); + assertThat(entities.get(0).getId(), is(m.getResourcesTag())); + + setTimeOffset(-60000); + + try { + session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("6.0.0"); + em.flush(); + + setTimeOffset(0); + + entities = getMigrationEntities(em); + assertThat(entities.size(), is(2)); + + logger.info("MigrationModelEntity entries: "); + entities.forEach(entity -> log.infof("--id: %s; %s; %s", entity.getId(), entity.getVersion(), entity.getUpdateTime())); + + assertMigrationModelEntity(entities.get(0), currentVersion); + assertMigrationModelEntity(entities.get(1), "6.0.0"); + + m = session.getProvider(DeploymentStateProvider.class).getMigrationModel(); + assertThat(m.getStoredVersion(), is(currentVersion)); + assertThat(entities.get(0).getId(), is(m.getResourcesTag())); + } finally { + em.remove(entities.get(1)); + } + + return null; + }); + } + + @Test + public void duplicates() { + inComittedTransaction(1, (session, i) -> { + String currentVersion = new ModelVersion(Version.VERSION).toString(); + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + List entities = getMigrationEntities(em); + assertThat(entities.size(), is(1)); + assertMigrationModelEntity(entities.get(0), currentVersion); + + MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel(); + assertThat(m.getStoredVersion(), is(currentVersion)); + assertThat(entities.get(0).getId(), is(m.getResourcesTag())); + + return null; + }); + + setTimeOffset(-60000); + inComittedTransaction(session -> { + session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("26.2.4"); + return null; + }); + + setTimeOffset(-30000); + inComittedTransaction(session -> { + session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("26.2.5"); + return null; + }); + + setTimeOffset(0); + + inComittedTransaction(1, (session, i) -> { + String currentVersion = new ModelVersion(Version.VERSION).toString(); + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + List entities = getMigrationEntities(em); + assertThat(entities.size(), is(3)); + + logger.info("MigrationModelEntity entries: "); + entities.forEach(entity -> log.infof("--id: %s; %s; %s", entity.getId(), entity.getVersion(), entity.getUpdateTime())); + + assertMigrationModelEntity(entities.get(0), currentVersion); + assertMigrationModelEntity(entities.get(1), "26.2.5"); + assertMigrationModelEntity(entities.get(2), "26.2.4"); + + return null; + }); + + setTimeOffset(-29999); + assertThrows(ModelDuplicateException.class, () -> + inComittedTransaction(session -> { + session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("26.2.5"); + return null; + }) + ); + + inComittedTransaction(1, (session, i) -> { + String currentVersion = new ModelVersion(Version.VERSION).toString(); + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + List entities = getMigrationEntities(em); + assertThat(entities.size(), is(3)); + + assertMigrationModelEntity(entities.get(0), currentVersion); + assertMigrationModelEntity(entities.get(1), "26.2.5"); + assertMigrationModelEntity(entities.get(2), "26.2.4"); + + MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel(); + assertThat(m.getStoredVersion(), is(currentVersion)); + assertThat(entities.get(0).getId(), is(m.getResourcesTag())); + + em.remove(entities.get(1)); + em.remove(entities.get(2)); + + return null; + }); + } + + @Test + public void duplicatedUpdateTime() { + inComittedTransaction(1, (session, i) -> { + String currentVersion = new ModelVersion(Version.VERSION).toString(); + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + + List entities = getMigrationEntities(em); + assertThat(entities.size(), is(1)); + assertMigrationModelEntity(entities.get(0), currentVersion); + + MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel(); + assertThat(m.getStoredVersion(), is(currentVersion)); + assertThat(entities.get(0).getId(), is(m.getResourcesTag())); + + try { + MigrationModelEntity mm1 = new MigrationModelEntity(); + mm1.setId("a"); + mm1.setUpdatedTime(0); + mm1.setVersion("26.0.0"); + em.persist(mm1); + + em.flush(); + + // Same time, everything different - testing for the constraint to be present + MigrationModelEntity mm2 = new MigrationModelEntity(); + mm2.setId("b"); + mm2.setUpdatedTime(0); + mm2.setVersion("26.0.1"); + em.persist(mm2); + + // added at the same time - exception thrown by the unique constraint + assertThrows(ModelDuplicateException.class, em::flush); + + } finally { + em.remove(em.find(MigrationModelEntity.class, "a")); + } + + return null; + }); + } + + + private void assertMigrationModelEntity(MigrationModelEntity model, String expectedVersion) { + assertThat(model, notNullValue()); + assertTrue(model.getId().matches("[\\da-z]{5}")); + assertThat(model.getVersion(), is(expectedVersion)); + } + + private List getMigrationEntities(EntityManager em) { + return em.createQuery("select m from MigrationModelEntity m ORDER BY m.updatedTime DESC", MigrationModelEntity.class).getResultList(); + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java new file mode 100644 index 00000000..6d949e4c --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java @@ -0,0 +1,167 @@ +package tech.ydb.keycloak.testsuite; + +import org.keycloak.Config.ConfigProvider; +import org.keycloak.Config.Scope; +import org.keycloak.Config.SystemPropertiesScope; +import org.keycloak.common.util.StringPropertyReplacer; +import org.keycloak.common.util.SystemEnvProperties; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BooleanSupplier; +import java.util.stream.Collectors; + +public class Config implements ConfigProvider { + + private final Map defaultProperties = new ConcurrentHashMap<>(); + private final ThreadLocal> properties = new ThreadLocal>() { + @Override + protected Map initialValue() { + return new HashMap<>(); + } + }; + private final BooleanSupplier useGlobalConfigurationFunc; + + public Config(BooleanSupplier useGlobalConfigurationFunc) { + this.useGlobalConfigurationFunc = useGlobalConfigurationFunc; + } + + void reset() { + if (useGlobalConfigurationFunc.getAsBoolean()) { + defaultProperties.clear(); + } else { + properties.remove(); + } + } + + public class SpiConfig { + + private final String prefix; + + public SpiConfig(String prefix) { + this.prefix = prefix; + } + + public ProviderConfig provider(String provider) { + return new ProviderConfig(this, prefix + provider + "."); + } + + public SpiConfig defaultProvider(String defaultProviderId) { + return config("provider", defaultProviderId); + } + + public SpiConfig config(String key, String value) { + if (value == null) { + getConfig().remove(prefix + key); + } else { + getConfig().put(prefix + key, value); + } + return this; + } + + public SpiConfig spi(String spiName) { + return new SpiConfig(spiName + "."); + } + } + + public class ProviderConfig { + + private final SpiConfig spiConfig; + private final String prefix; + + public ProviderConfig(SpiConfig spiConfig, String prefix) { + this.spiConfig = spiConfig; + this.prefix = prefix; + } + + public ProviderConfig config(String key, String value) { + if (value == null) { + getConfig().remove(prefix + key); + } else { + getConfig().put(prefix + key, value); + } + return this; + } + + public ProviderConfig provider(String provider) { + return spiConfig.provider(provider); + } + + public SpiConfig spi(String spiName) { + return new SpiConfig(spiName + "."); + } + } + + private class MapConfigScope extends SystemPropertiesScope { + + public MapConfigScope(String prefix) { + super(prefix); + } + + @Override + public String get(String key) { + return get(key, null); + } + + @Override + public String get(String key, String defaultValue) { + String fullKey = prefix + key; + String v = replaceProperties(getConfig().get(fullKey)); + if (v == null || v.isEmpty()) { + v = System.getProperty("keycloak." + fullKey, defaultValue); + } + return v != null && !v.isEmpty() ? v : defaultValue; + } + + @Override + public Scope scope(String... scope) { + StringBuilder sb = new StringBuilder(); + sb.append(prefix); + for (String s : scope) { + sb.append(s); + sb.append("."); + } + return new MapConfigScope(sb.toString()); + } + } + + @Override + public String getProvider(String spiName) { + return getConfig().get(spiName + ".provider"); + } + + public String getDefaultProvider(String spiName) { + return getConfig().get(spiName + ".provider.default"); + } + + public Map getConfig() { + return useGlobalConfigurationFunc.getAsBoolean() ? defaultProperties : properties.get(); + } + + private String replaceProperties(String value) { + return StringPropertyReplacer.replaceProperties(value, SystemEnvProperties.UNFILTERED::getProperty); + } + + @Override + public Scope scope(String... scope) { + StringBuilder sb = new StringBuilder(); + for (String s : scope) { + sb.append(s); + sb.append("."); + } + return new MapConfigScope(sb.toString()); + } + + public SpiConfig spi(String spiName) { + return new SpiConfig(spiName + "."); + } + + @Override + public String toString() { + return getConfig().entrySet().stream() + .sorted((e1, e2) -> e1.getKey().compareTo(e2.getKey())) + .map(e -> e.getKey() + " = " + e.getValue()) + .collect(Collectors.joining("\n ")); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/DBLockTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/DBLockTest.java new file mode 100644 index 00000000..5f8a9f22 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/DBLockTest.java @@ -0,0 +1,312 @@ +package tech.ydb.keycloak.testsuite; + +import org.jboss.logging.Logger; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.dblock.DBLockManager; +import org.keycloak.models.dblock.DBLockProvider; +import org.keycloak.models.dblock.DBLockProviderFactory; +import org.keycloak.models.utils.KeycloakModelUtils; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +@RequireProvider(value=DBLockProvider.class, only="ydb") +public class DBLockTest extends KeycloakModelTest { + + private static final Logger log = Logger.getLogger(DBLockTest.class); + + private static final int SLEEP_TIME_MILLIS = 10; + private static final int SLEEP_TIME_MILLIS_FOR_TWO_LOCKS = 50; + private static final int THREADS_COUNT = 20; + private static final int THREADS_COUNT_MEDIUM = 12; + private static final int ITERATIONS_PER_THREAD = 2; + private static final int ITERATIONS_PER_THREAD_MEDIUM = 4; + private static final int ITERATIONS_PER_THREAD_LONG = 20; + + private static final int LOCK_TIMEOUT_MILLIS = 240000; // Rather bigger to handle slow DB connections in testing env + private static final int LOCK_RECHECK_MILLIS = 10; + + @Before + public void before() { + inComittedTransaction(1, (session , i) -> { + // Set timeouts for testing + DBLockManager lockManager = new DBLockManager(session); + DBLockProviderFactory lockFactory = lockManager.getDBLockFactory(); + lockFactory.setTimeouts(LOCK_RECHECK_MILLIS, LOCK_TIMEOUT_MILLIS); + + // Drop lock table, just to simulate racing threads for create lock table and insert lock record into it. + lockManager.getDBLock().destroyLockInfo(); + return null; + }); + } + + @Test + public void simpleLockTest() { + inComittedTransaction(1, (session , i) -> { + DBLockProvider dbLock = new DBLockManager(session).getDBLock(); + dbLock.waitForLock(DBLockProvider.Namespace.DATABASE); + System.out.printf("Lock acquired: %s\n", i); + try { + Assert.assertEquals(DBLockProvider.Namespace.DATABASE, dbLock.getCurrentLock()); + } finally { + System.out.printf("Lock released: %s\n", i); + dbLock.releaseLock(); + } + Assert.assertNull(dbLock.getCurrentLock()); + return null; + }); + } + + @Test + public void simpleNestedLockTest() { + inComittedTransaction(1, (session , i) -> { + // first session lock DATABASE + DBLockProvider dbLock1 = new DBLockManager(session).getDBLock(); + dbLock1.waitForLock(DBLockProvider.Namespace.DATABASE); + try { + Assert.assertEquals(DBLockProvider.Namespace.DATABASE, dbLock1.getCurrentLock()); + KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession sessionLC2) -> { + // a second session/dblock-provider can lock another namespace OFFLINE_SESSIONS + DBLockProvider dbLock2 = new DBLockManager(sessionLC2).getDBLock(); + dbLock2.waitForLock(DBLockProvider.Namespace.KEYCLOAK_BOOT); + try { + // getCurrentLock is local, each provider instance has one + Assert.assertEquals(DBLockProvider.Namespace.KEYCLOAK_BOOT, dbLock2.getCurrentLock()); + } finally { + dbLock2.releaseLock(); + } + Assert.assertNull(dbLock2.getCurrentLock()); + }); + } finally { + dbLock1.releaseLock(); + } + Assert.assertNull(dbLock1.getCurrentLock()); + return null; + }); + } + + @Test + public void testLockConcurrentlyGeneral() { + inComittedTransaction(1, (session , i) -> { + testLockConcurrentlyInternal(session, DBLockProvider.Namespace.DATABASE); + return null; + }); + } + + @Test + public void testLockConcurrentlyKeycloakBoot() { + inComittedTransaction(1, (session , i) -> { + testLockConcurrentlyInternal(session, DBLockProvider.Namespace.KEYCLOAK_BOOT); + return null; + }); + } + + @Test + public void testTwoLocksCurrently() { + inComittedTransaction(1, (session , i) -> { + testTwoLocksCurrentlyInternal(session, DBLockProvider.Namespace.DATABASE, DBLockProvider.Namespace.KEYCLOAK_BOOT); + return null; + }); + } + + @Test + public void testTwoNestedLocksCurrently() { + inComittedTransaction(1, (session , i) -> { + testTwoNestedLocksCurrentlyInternal(session, DBLockProvider.Namespace.KEYCLOAK_BOOT, DBLockProvider.Namespace.DATABASE); + return null; + }); + } + + private void testLockConcurrentlyInternal(KeycloakSession sessionLC, DBLockProvider.Namespace lock) { + long startupTime = System.currentTimeMillis(); + + final Semaphore semaphore = new Semaphore(); + final KeycloakSessionFactory sessionFactory = sessionLC.getKeycloakSessionFactory(); + + List threads = new LinkedList<>(); + + for (int i = 0; i < THREADS_COUNT; i++) { + Thread thread = new Thread(() -> { + for (int j = 0; j < ITERATIONS_PER_THREAD; j++) { + try { + KeycloakModelUtils.runJobInTransaction(sessionFactory, session1 -> + lock(session1, lock, semaphore)); + } catch (RuntimeException e) { + semaphore.setException(e); + throw e; + } + } + }); + + threads.add(thread); + } + + for (Thread thread : threads) { + thread.start(); + } + for (Thread thread : threads) { + try { + thread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + long took = (System.currentTimeMillis() - startupTime); + log.infof("DBLockTest executed in %d ms with total counter %d. THREADS_COUNT=%d, ITERATIONS_PER_THREAD=%d", took, semaphore.getTotal(), THREADS_COUNT, ITERATIONS_PER_THREAD); + + Assert.assertEquals(THREADS_COUNT * ITERATIONS_PER_THREAD, semaphore.getTotal()); + Assert.assertNull(semaphore.getException()); + } + + private void testTwoLocksCurrentlyInternal(KeycloakSession sessionLC, DBLockProvider.Namespace lock1, DBLockProvider.Namespace lock2) { + final Semaphore semaphore = new Semaphore(); + final KeycloakSessionFactory sessionFactory = sessionLC.getKeycloakSessionFactory(); + List threads = new LinkedList<>(); + // launch two threads and expect an error because the locks are different + for (int i = 0; i < 2; i++) { + final DBLockProvider.Namespace lock = (i % 2 == 0)? lock1 : lock2; + Thread thread = new Thread(() -> { + IntStream.range(0, ITERATIONS_PER_THREAD_LONG).parallel().forEach(j -> { + try { + KeycloakModelUtils.runJobInTransaction(sessionFactory, session1 -> lock(session1, lock, semaphore, SLEEP_TIME_MILLIS_FOR_TWO_LOCKS)); + } catch (RuntimeException e) { + semaphore.setException(e); + } + }); + }); + threads.add(thread); + } + + threads.parallelStream().forEach(Thread::start); + + for (Thread thread : threads) { + try { + thread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + // interference is needed because different namespaces can interfere + Assert.assertNotNull(semaphore.getException()); + } + + private void testTwoNestedLocksCurrentlyInternal(KeycloakSession sessionLC, DBLockProvider.Namespace lockTop, DBLockProvider.Namespace lockInner) { + final Semaphore semaphore = new Semaphore(); + final KeycloakSessionFactory sessionFactory = sessionLC.getKeycloakSessionFactory(); + List threads = new LinkedList<>(); + // launch two threads and expect an error because the locks are different + for (int i = 0; i < THREADS_COUNT_MEDIUM; i++) { + final boolean nested = i % 2 == 0; + Thread thread = new Thread(() -> { + for (int j = 0; j < ITERATIONS_PER_THREAD_MEDIUM; j++) { + try { + if (nested) { + // half the threads run two level lock top-inner + KeycloakModelUtils.runJobInTransaction(sessionFactory, + session1 -> nestedTwoLevelLock(session1, lockTop, lockInner, semaphore)); + } else { + // the other half only run a lock in the top namespace + KeycloakModelUtils.runJobInTransaction(sessionFactory, + session1 -> lock(session1, lockTop, semaphore)); + } + } catch (RuntimeException e) { + semaphore.setException(e); + } + } + }); + threads.add(thread); + } + for (Thread thread : threads) { + thread.start(); + } + for (Thread thread : threads) { + try { + thread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + Assert.assertEquals(THREADS_COUNT_MEDIUM * ITERATIONS_PER_THREAD_MEDIUM, semaphore.getTotal()); + Assert.assertNull(semaphore.getException()); + } + + private void lock(KeycloakSession session, DBLockProvider.Namespace lock, Semaphore semaphore) { + this.lock(session, lock, semaphore, SLEEP_TIME_MILLIS); + } + + private void lock(KeycloakSession session, DBLockProvider.Namespace lock, Semaphore semaphore, long sleepTime) { + DBLockProvider dbLock = new DBLockManager(session).getDBLock(); + dbLock.waitForLock(lock); + try { + semaphore.increase(); + Thread.sleep(sleepTime); + semaphore.decrease(); + } catch (InterruptedException ie) { + throw new RuntimeException(ie); + } finally { + dbLock.releaseLock(); + } + } + + private void nestedTwoLevelLock(KeycloakSession session, DBLockProvider.Namespace lockTop, + DBLockProvider.Namespace lockInner, Semaphore semaphore) { + DBLockProvider dbLock = new DBLockManager(session).getDBLock(); + dbLock.waitForLock(lockTop); + try { + // create a new session to call the lock method with the inner namespace + KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), + sessionInner -> lock(sessionInner, lockInner, semaphore)); + } finally { + dbLock.releaseLock(); + } + } + + // Ensure just one thread is allowed to run at the same time + private class Semaphore { + + private final AtomicInteger counter = new AtomicInteger(0); + private final AtomicInteger totalIncreases = new AtomicInteger(0); + + private volatile Exception exception = null; + + private void increase() { + int current = counter.incrementAndGet(); + if (current != 1) { + IllegalStateException ex = new IllegalStateException("Counter has illegal value: " + current); + setException(ex); + throw ex; + } + totalIncreases.incrementAndGet(); + } + + private void decrease() { + int current = counter.decrementAndGet(); + if (current != 0) { + IllegalStateException ex = new IllegalStateException("Counter has illegal value: " + current); + setException(ex); + throw ex; + } + } + + private synchronized void setException(Exception exception) { + if (this.exception == null) { + this.exception = exception; + } + } + + private synchronized Exception getException() { + return exception; + } + + private int getTotal() { + return totalIncreases.get(); + } + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelParameters.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelParameters.java new file mode 100644 index 00000000..db14e936 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelParameters.java @@ -0,0 +1,58 @@ +package tech.ydb.keycloak.testsuite; + +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +import java.util.Set; +import java.util.stream.Stream; + +public class KeycloakModelParameters { + + private final Set> allowedSpis; + private final Set> allowedFactories; + + public KeycloakModelParameters( + Set> allowedSpis, Set> allowedFactories) { + this.allowedSpis = allowedSpis; + this.allowedFactories = allowedFactories; + } + + boolean isSpiAllowed(Spi s) { + return allowedSpis.contains(s.getClass()); + } + + boolean isFactoryAllowed(ProviderFactory factory) { + return allowedFactories.stream().anyMatch((c) -> c.isAssignableFrom(factory.getClass())); + } + + /** + * Returns stream of parameters of the given type, or an empty stream if no parameters of the given type are supplied + * by this clazz. + * + * @param + * @param clazz + * @return + */ + public Stream getParameters(Class clazz) { + return Stream.empty(); + } + + public void updateConfig(Config cf) { + } + + public Statement classRule(Statement base, Description description) { + return base; + } + + public Statement instanceRule(Statement base, Description description) { + return base; + } + + public void beforeSuite(Config cf) { + } + + public void afterSuite() { + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java new file mode 100644 index 00000000..01622f0f --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java @@ -0,0 +1,560 @@ +package tech.ydb.keycloak.testsuite; + +import org.hamcrest.Matchers; +import org.jboss.logging.Logger; +import org.junit.*; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.keycloak.Config.Scope; +import org.keycloak.authorization.AuthorizationSpi; +import org.keycloak.authorization.DefaultAuthorizationProviderFactory; +import org.keycloak.authorization.policy.provider.PolicyProviderFactory; +import org.keycloak.authorization.policy.provider.PolicySpi; +import org.keycloak.authorization.store.StoreFactorySpi; +import org.keycloak.cluster.ClusterSpi; +import org.keycloak.common.Profile; +import org.keycloak.common.profile.PropertiesProfileConfigResolver; +import org.keycloak.common.util.Time; +import org.keycloak.component.ComponentFactoryProviderFactory; +import org.keycloak.component.ComponentFactorySpi; +import org.keycloak.events.EventStoreSpi; +import org.keycloak.executors.DefaultExecutorsProviderFactory; +import org.keycloak.executors.ExecutorsSpi; +import org.keycloak.models.*; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.PostMigrationEvent; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.ProviderManager; +import org.keycloak.provider.Spi; +import org.keycloak.quarkus.runtime.integration.resteasy.QuarkusKeycloakContext; +import org.keycloak.services.DefaultComponentFactoryProviderFactory; +import org.keycloak.services.DefaultKeycloakContext; +import org.keycloak.services.DefaultKeycloakSession; +import org.keycloak.services.DefaultKeycloakSessionFactory; +import org.keycloak.spi.infinispan.CacheRemoteConfigProviderFactory; +import org.keycloak.spi.infinispan.CacheRemoteConfigProviderSpi; +import org.keycloak.storage.DatastoreProviderFactory; +import org.keycloak.storage.DatastoreSpi; +import org.keycloak.timer.TimerSpi; +import org.keycloak.tracing.TracingProviderFactory; +import org.keycloak.tracing.TracingSpi; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +public abstract class KeycloakModelTest { + private static final Logger LOG = Logger.getLogger(KeycloakModelParameters.class); + private static final AtomicInteger FACTORY_COUNT = new AtomicInteger(); + protected final Logger log = Logger.getLogger(getClass()); + private static final List MAIN_THREAD_NAMES = Arrays.asList("main", "Time-limited test"); + + @ClassRule + public static final TestRule GUARANTEE_REQUIRED_FACTORY = new TestRule() { + @Override + public Statement apply(Statement base, Description description) { + Class testClass = description.getTestClass(); + Stream st = Stream.empty(); + while (testClass != Object.class) { + st = Stream.concat(Stream.of(testClass.getAnnotationsByType(RequireProvider.class)), st); + testClass = testClass.getSuperclass(); + } + List> notFound = st.filter(KeycloakModelTest::checkProviderAvailability) + .map(RequireProvider::value) + .collect(Collectors.toList()); + Assume.assumeThat("Some required providers not found", notFound, Matchers.empty()); + + Statement res = base; + for (KeycloakModelParameters kmp : KeycloakModelTest.MODEL_PARAMETERS) { + res = kmp.classRule(res, description); + } + return res; + } + }; + + private static boolean checkProviderAvailability(RequireProvider annotation) { + Set allFactories = getFactory() + .getProviderFactoriesStream(annotation.value()) + .map(ProviderFactory::getId) + .collect(Collectors.toSet()); + List only = Arrays.asList(annotation.only()); + List exclude = Arrays.asList(annotation.exclude()); + + if (allFactories.isEmpty()) return true; + allFactories.removeIf(exclude::contains); + allFactories.removeIf(id -> !only.isEmpty() && !only.contains(id)); + return allFactories.isEmpty(); + } + + @Rule + public final TestRule guaranteeRequiredFactoryOnMethod = new TestRule() { + @Override + public Statement apply(Statement base, Description description) { + Stream st = Optional.ofNullable(description.getAnnotation(RequireProviders.class)) + .map(RequireProviders::value) + .map(Stream::of) + .orElseGet(Stream::empty); + + RequireProvider rp = description.getAnnotation(RequireProvider.class); + if (rp != null) { + st = Stream.concat(st, Stream.of(rp)); + } + + for (Iterator iterator = st.iterator(); iterator.hasNext(); ) { + RequireProvider rpInner = iterator.next(); + Class providerClass = rpInner.value(); + String[] only = rpInner.only(); + + if (only.length == 0) { + if (getFactory().getProviderFactory(providerClass) == null) { + return new Statement() { + @Override + public void evaluate() { + throw new AssumptionViolatedException("Provider must exist: " + providerClass); + } + }; + } + } else { + boolean notFoundAny = Stream.of(only) + .allMatch(provider -> getFactory().getProviderFactory(providerClass, provider) == null); + if (notFoundAny) { + return new Statement() { + @Override + public void evaluate() { + throw new AssumptionViolatedException("Provider must exist: " + + providerClass + " one of [" + String.join(",", only) + "]"); + } + }; + } + } + } + + Statement res = base; + for (KeycloakModelParameters kmp : KeycloakModelTest.MODEL_PARAMETERS) { + res = kmp.instanceRule(res, description); + } + return res; + } + }; + + @Rule + public final TestRule watcher = new TestWatcher() { + @Override + protected void starting(Description description) { + log.infof("%s STARTED", description.getMethodName()); + } + + @Override + protected void finished(Description description) { + log.infof("%s FINISHED\n\n", description.getMethodName()); + } + }; + + private static final Set> ALLOWED_SPIS = Set.of( + AuthorizationSpi.class, + PolicySpi.class, + ClientScopeSpi.class, + ClientSpi.class, + ComponentFactorySpi.class, + ClusterSpi.class, + CacheRemoteConfigProviderSpi.class, + EventStoreSpi.class, + ExecutorsSpi.class, + GroupSpi.class, + RealmSpi.class, + RoleSpi.class, + DeploymentStateSpi.class, + StoreFactorySpi.class, + TimerSpi.class, + TracingSpi.class, + UserLoginFailureSpi.class, + UserSessionSpi.class, + UserSpi.class, + DatastoreSpi.class + ); + + private static final Set> ALLOWED_FACTORIES = Set.of( + ComponentFactoryProviderFactory.class, + DefaultAuthorizationProviderFactory.class, + PolicyProviderFactory.class, + DefaultExecutorsProviderFactory.class, + DeploymentStateProviderFactory.class, + DatastoreProviderFactory.class, + TracingProviderFactory.class, + CacheRemoteConfigProviderFactory.class); + + protected static final List MODEL_PARAMETERS; + protected static final Config CONFIG = new Config(KeycloakModelTest::useDefaultFactory); + private static volatile KeycloakSessionFactory DEFAULT_FACTORY; + private static final ThreadLocal LOCAL_FACTORY = new ThreadLocal<>(); + protected static boolean USE_DEFAULT_FACTORY = false; + + static { + org.keycloak.Config.init(CONFIG); + + KeycloakModelParameters basicParameters = new KeycloakModelParameters(ALLOWED_SPIS, ALLOWED_FACTORIES); + MODEL_PARAMETERS = Stream.concat( + Stream.of(basicParameters), + Stream.of(System.getProperty("keycloak.model.parameters", "").split("\\s*,\\s*")) + .filter(s -> s != null && !s.trim().isEmpty()) + .map(cn -> { + try { + return Class.forName( + cn.indexOf('.') >= 0 ? cn + : ("tech.ydb.keycloak.testsuite.parameters." + cn)); + } catch (Exception e) { + LOG.error("Cannot find " + cn); + return null; + } + }) + .filter(Objects::nonNull) + .map(c -> { + try { + return c.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + LOG.error("Cannot instantiate " + c); + return null; + } + }) + .filter(KeycloakModelParameters.class::isInstance) + .map(KeycloakModelParameters.class::cast)) + .collect(Collectors.toList()); + + for (KeycloakModelParameters kmp : KeycloakModelTest.MODEL_PARAMETERS) { + kmp.beforeSuite(CONFIG); + } + + reinitializeKeycloakSessionFactory(); + DEFAULT_FACTORY = getFactory(); + } + + public static KeycloakSessionFactory createKeycloakSessionFactory() { + int factoryIndex = FACTORY_COUNT.incrementAndGet(); + String threadName = Thread.currentThread().getName(); + CONFIG.reset(); + CONFIG.spi(ComponentFactorySpi.NAME) + .provider(DefaultComponentFactoryProviderFactory.PROVIDER_ID) + .config("cachingForced", "true"); + MODEL_PARAMETERS.forEach(m -> m.updateConfig(CONFIG)); + + LOG.debugf("Creating factory %d in %s using the following configuration:\n %s", + factoryIndex, threadName, CONFIG); + + DefaultKeycloakSessionFactory res = new DefaultKeycloakSessionFactory() { + @Override + public KeycloakSession create() { + return new DefaultKeycloakSession(this) { + @Override + protected DefaultKeycloakContext createKeycloakContext(KeycloakSession keycloakSession) { + return new QuarkusKeycloakContext(this); + } + }; + } + + @Override + public void init() { + Profile.configure(new PropertiesProfileConfigResolver(System.getProperties())); + super.init(); + } + + @Override + protected boolean isEnabled(ProviderFactory factory, Scope scope) { + return super.isEnabled(factory, scope) && isFactoryAllowed(factory); + } + + @Override + protected Map, Map> loadFactories(ProviderManager pm) { + spis.removeIf(s -> !isSpiAllowed(s)); + return super.loadFactories(pm); + } + + private boolean isSpiAllowed(Spi s) { + return MODEL_PARAMETERS.stream().anyMatch(p -> p.isSpiAllowed(s)); + } + + private boolean isFactoryAllowed(ProviderFactory factory) { + return MODEL_PARAMETERS.stream().anyMatch(p -> p.isFactoryAllowed(factory)); + } + + @Override + public String toString() { + return "KeycloakSessionFactory " + factoryIndex + " (from " + threadName + " thread)"; + } + }; + res.init(); + res.publish(new PostMigrationEvent(res)); + return res; + } + + public static synchronized void reinitializeKeycloakSessionFactory() { + closeKeycloakSessionFactory(); + setFactory(createKeycloakSessionFactory()); + } + + public static synchronized void closeKeycloakSessionFactory() { + KeycloakSessionFactory f = getFactory(); + setFactory(null); + if (f != null) { + LOG.debugf("Closing %s", f); + f.close(); + } + } + + public static void inIndependentFactories(int numThreads, int timeoutSeconds, Runnable task) + throws InterruptedException { + if (!ManagementFactory.getThreadMXBean().isThreadContentionMonitoringEnabled()) { + ManagementFactory.getThreadMXBean().setThreadContentionMonitoringEnabled(true); + } + LinkedList threads = new LinkedList<>(); + ExecutorService es = Executors.newFixedThreadPool(numThreads, r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + threads.add(t); + return t; + }); + try { + CountDownLatch start = new CountDownLatch(numThreads); + CountDownLatch stop = new CountDownLatch(numThreads); + Callable independentTask = () -> inIndependentFactory(() -> { + start.countDown(); + start.await(); + try { + task.run(); + } finally { + stop.countDown(); + } + stop.await(); + return null; + }); + List> tasks = IntStream.range(0, numThreads) + .mapToObj(i -> independentTask) + .map(es::submit) + .collect(Collectors.toList()); + long limit = System.currentTimeMillis() + timeoutSeconds * 1000L; + for (Future future : tasks) { + long limitForTask = limit - System.currentTimeMillis(); + if (limitForTask > 0) { + try { + future.get(limitForTask, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + if (e.getCause() instanceof AssertionError) { + throw (AssertionError) e.getCause(); + } else { + LOG.error("Execution didn't complete", e); + Assert.fail("Execution didn't complete: " + e.getMessage()); + } + } catch (TimeoutException e) { + ThreadInfo[] infos = ManagementFactory.getThreadMXBean().dumpAllThreads(true, true); + throw new AssertionError("threads didn't terminate: " + Arrays.toString(infos), e); + } + } + } + } finally { + es.shutdownNow(); + } + if (!es.awaitTermination(10, TimeUnit.SECONDS)) { + Assert.fail("Executor did not terminate"); + } + } + + public static T inIndependentFactory(Callable task) { + if (USE_DEFAULT_FACTORY) { + throw new IllegalStateException("USE_DEFAULT_FACTORY must be false to use an independent factory"); + } + KeycloakSessionFactory original = getFactory(); + try { + setFactory(createKeycloakSessionFactory()); + return task.call(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } finally { + closeKeycloakSessionFactory(); + setFactory(original); + } + } + + protected static boolean useDefaultFactory() { + return USE_DEFAULT_FACTORY || MAIN_THREAD_NAMES.contains(Thread.currentThread().getName()); + } + + protected static KeycloakSessionFactory getFactory() { + return useDefaultFactory() ? DEFAULT_FACTORY : LOCAL_FACTORY.get(); + } + + private static void setFactory(KeycloakSessionFactory factory) { + if (useDefaultFactory()) { + DEFAULT_FACTORY = factory; + } else { + LOCAL_FACTORY.set(factory); + } + } + + @BeforeClass + public static void checkValidParameters() { + Assume.assumeTrue("keycloak.model.parameters property must be set", MODEL_PARAMETERS.size() > 1); + } + + protected void createEnvironment(KeycloakSession s) { + } + + protected void cleanEnvironment(KeycloakSession s) { + } + + @Before + public final void createEnvironment() { + Time.setOffset(0); + USE_DEFAULT_FACTORY = isUseSameKeycloakSessionFactoryForAllThreads(); + KeycloakModelUtils.runJobInTransaction(getFactory(), this::createEnvironment); + } + + @After + public final void cleanEnvironment() { + if (getFactory() == null) { + reinitializeKeycloakSessionFactory(); + } + Time.setOffset(0); + KeycloakModelUtils.runJobInTransaction(getFactory(), this::cleanEnvironment); + } + + protected Stream getParameters(Class clazz) { + return MODEL_PARAMETERS.stream().flatMap(mp -> mp.getParameters(clazz)).filter(Objects::nonNull); + } + + protected void inRolledBackTransaction(T parameter, BiConsumer what) { + try (KeycloakSession session = getFactory().create()) { + session.getTransactionManager().begin(); + + what.accept(session, parameter); + + session.getTransactionManager().setRollbackOnly(); + } + } + + protected R inComittedTransaction(T parameter, BiFunction what) { + return inComittedTransaction(parameter, what, null, null); + } + + protected void inComittedTransaction(Consumer what) { + inComittedTransaction(a -> { + what.accept(a); + return null; + }); + } + + protected R inComittedTransaction(Function what) { + return inComittedTransaction(1, (a, b) -> what.apply(a), null, null); + } + + protected R inComittedTransaction( + T parameter, BiFunction what, BiConsumer onCommit) { + return inComittedTransaction(parameter, what, onCommit, null); + } + + protected R inComittedTransaction( + T parameter, BiFunction what, + BiConsumer onCommit, BiConsumer onRollback) { + AtomicReference res = new AtomicReference<>(); + KeycloakModelUtils.runJobInTransaction(getFactory(), session -> { + session.getTransactionManager().enlistAfterCompletion(new AbstractKeycloakTransaction() { + @Override + protected void commitImpl() { + if (onCommit != null) onCommit.accept(session, parameter); + } + + @Override + protected void rollbackImpl() { + if (onRollback != null) onRollback.accept(session, parameter); + } + }); + res.set(what.apply(session, parameter)); + }); + return res.get(); + } + + protected R withRealm(String realmId, BiFunction what) { + return inComittedTransaction(session -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + return what.apply(session, realm); + }); + } + + protected void withRealmConsumer(String realmId, BiConsumer what) { + withRealm(realmId, (session, realm) -> { + what.accept(session, realm); + return null; + }); + } + + protected boolean isUseSameKeycloakSessionFactoryForAllThreads() { + return false; + } + + protected void sleep(long timeMs) { + try { + Thread.sleep(timeMs); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new RuntimeException(ex); + } + } + + protected static RealmModel createRealm(KeycloakSession s, String name) { + RealmModel realm = s.realms().getRealmByName(name); + if (realm != null) { + s.getContext().setRealm(realm); + s.realms().removeRealm(realm.getId()); + } + return s.realms().createRealm(name); + } + + /** + * Moves time on the Keycloak server + * @param seconds time offset in seconds by which Keycloak server time is moved + */ + protected void setTimeOffset(int seconds) { + inComittedTransaction(session -> { + Time.setOffset(seconds); + }); + } + + + public static void eventually(Supplier message, BooleanSupplier condition) { + eventually(message, condition, 5000, 10, MILLISECONDS); + } + + public static void eventually(Supplier message, BooleanSupplier condition, long timeout, + long pollInterval, TimeUnit unit) { + if (pollInterval <= 0) { + throw new IllegalArgumentException("Check interval must be positive"); + } + if (message == null) { + message = () -> null; + } + try { + long expectedEndTime = System.nanoTime() + TimeUnit.NANOSECONDS.convert(timeout, unit); + long sleepMillis = MILLISECONDS.convert(pollInterval, unit); + do { + if (condition.getAsBoolean()) return; + + Thread.sleep(sleepMillis); + } while (expectedEndTime - System.nanoTime() > 0); + + } catch (Exception e) { + throw new RuntimeException("Unexpected!", e); + } + // last check + Assert.assertTrue(message.get(), condition.getAsBoolean()); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RealmModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RealmModelTest.java new file mode 100644 index 00000000..b4996067 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RealmModelTest.java @@ -0,0 +1,166 @@ +package tech.ydb.keycloak.testsuite; + +import org.junit.Test; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.models.*; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderEventListener; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@RequireProvider(RealmProvider.class) +public class RealmModelTest extends KeycloakModelTest { + + private String realmId; + private String realm1Id; + private String realm2Id; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + this.removeRealm(s, realmId); + if (realm1Id != null) this.removeRealm(s, realm1Id); + if (realm2Id != null) this.removeRealm(s, realm2Id); + } + + private void removeRealm(KeycloakSession s, String realmId) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testRealmLocalizationTexts() { + withRealm(realmId, (session, realm) -> { + // Assert emptyMap + assertThat(realm.getRealmLocalizationTexts(), anEmptyMap()); + // Add a localization test + session.realms().saveLocalizationText(realm, "en", "key-a", "text-a_en"); + return null; + }); + + withRealm(realmId, (session, realm) -> { + // Assert the map contains the added value + assertThat(realm.getRealmLocalizationTexts(), aMapWithSize(1)); + assertThat(realm.getRealmLocalizationTexts(), + hasEntry(equalTo("en"), allOf(aMapWithSize(1), + hasEntry(equalTo("key-a"), equalTo("text-a_en"))))); + + // Add another localization text to previous locale + session.realms().saveLocalizationText(realm, "en", "key-b", "text-b_en"); + return null; + }); + + withRealm(realmId, (session, realm) -> { + assertThat(realm.getRealmLocalizationTexts(), aMapWithSize(1)); + assertThat(realm.getRealmLocalizationTexts(), + hasEntry(equalTo("en"), allOf(aMapWithSize(2), + hasEntry(equalTo("key-a"), equalTo("text-a_en")), + hasEntry(equalTo("key-b"), equalTo("text-b_en"))))); + + // Add new locale + session.realms().saveLocalizationText(realm, "de", "key-a", "text-a_de"); + return null; + }); + + withRealm(realmId, (session, realm) -> { + // Check everything created successfully + assertThat(realm.getRealmLocalizationTexts(), aMapWithSize(2)); + assertThat(realm.getRealmLocalizationTexts(), + hasEntry(equalTo("en"), allOf(aMapWithSize(2), + hasEntry(equalTo("key-a"), equalTo("text-a_en")), + hasEntry(equalTo("key-b"), equalTo("text-b_en"))))); + assertThat(realm.getRealmLocalizationTexts(), + hasEntry(equalTo("de"), allOf(aMapWithSize(1), + hasEntry(equalTo("key-a"), equalTo("text-a_de"))))); + + return null; + }); + } + + @Test + public void testRealmPreRemoveDoesntRemoveEntitiesFromOtherRealms() { + realm1Id = inComittedTransaction(session -> { + RealmModel realm = session.realms().createRealm("realm1"); + realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + return realm.getId(); + }); + realm2Id = inComittedTransaction(session -> { + RealmModel realm = session.realms().createRealm("realm2"); + realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + return realm.getId(); + }); + + // Create client with resource server + String clientRealm1 = withRealm(realm1Id, (keycloakSession, realmModel) -> { + ClientModel clientRealm = realmModel.addClient("clientRealm1"); + AuthorizationProvider provider = keycloakSession.getProvider(AuthorizationProvider.class); + provider.getStoreFactory().getResourceServerStore().create(clientRealm); + + return clientRealm.getId(); + }); + + // Remove realm 2 + inComittedTransaction((Consumer) keycloakSession -> this.removeRealm(keycloakSession, realm2Id)); + + // ResourceServer in realm1 must still exist + ResourceServer resourceServer = withRealm(realm1Id, (keycloakSession, realmModel) -> { + ClientModel client1 = realmModel.getClientById(clientRealm1); + return keycloakSession.getProvider(AuthorizationProvider.class).getStoreFactory().getResourceServerStore().findByClient(client1); + }); + + assertThat(resourceServer, notNullValue()); + } + + @Test + public void testMoveGroup() { + ProviderEventListener providerEventListener = null; + try { + List groupPathChangeEvents = new ArrayList<>(); + providerEventListener = event -> { + if (event instanceof GroupModel.GroupPathChangeEvent) { + groupPathChangeEvents.add((GroupModel.GroupPathChangeEvent) event); + } + }; + getFactory().register(providerEventListener); + + withRealm(realmId, (session, realm) -> { + GroupModel groupA = realm.createGroup("a"); + GroupModel groupB = realm.createGroup("b"); + + final String previousPath = "/a"; + assertThat(KeycloakModelUtils.buildGroupPath(groupA), equalTo(previousPath)); + + realm.moveGroup(groupA, groupB); + + final String expectedNewPath = "/b/a"; + assertThat(KeycloakModelUtils.buildGroupPath(groupA), equalTo(expectedNewPath)); + + assertThat(groupPathChangeEvents, hasSize(1)); + GroupModel.GroupPathChangeEvent groupPathChangeEvent = groupPathChangeEvents.get(0); + assertThat(groupPathChangeEvent.getPreviousPath(), equalTo(previousPath)); + assertThat(groupPathChangeEvent.getNewPath(), equalTo(expectedNewPath)); + + return null; + }); + } finally { + if (providerEventListener != null) { + getFactory().unregister(providerEventListener); + } + } + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProvider.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProvider.java new file mode 100644 index 00000000..089a68a4 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProvider.java @@ -0,0 +1,16 @@ +package tech.ydb.keycloak.testsuite; + +import org.keycloak.provider.Provider; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +@Repeatable(RequireProviders.class) +public @interface RequireProvider { + Class value() default Provider.class; + + String[] only() default {}; + + String[] exclude() default {}; +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProviders.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProviders.java new file mode 100644 index 00000000..41225c02 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProviders.java @@ -0,0 +1,12 @@ +package tech.ydb.keycloak.testsuite; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequireProviders { + RequireProvider[] value(); +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/TimeOffsetTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/TimeOffsetTest.java new file mode 100644 index 00000000..c0f552b6 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/TimeOffsetTest.java @@ -0,0 +1,69 @@ +package tech.ydb.keycloak.testsuite; + +import org.junit.Test; +import org.keycloak.common.util.Time; +import org.keycloak.events.Event; +import org.keycloak.events.EventStoreProvider; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ProviderFactory; + +import static org.junit.Assert.assertEquals; + +@RequireProvider(EventStoreProvider.class) +public class TimeOffsetTest extends KeycloakModelTest { + + private String realmId; + + @Override + protected void createEnvironment(KeycloakSession s) { + RealmModel r = s.realms().createRealm("realm"); + s.getContext().setRealm(r); + r.setDefaultRole(s.roles().addRealmRole(r, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + r.getName())); + r.setEventsExpiration(5); + realmId = r.getId(); + } + + @Override + protected void cleanEnvironment(KeycloakSession s) { + RealmModel r = s.realms().getRealm(realmId); + s.getContext().setRealm(r); + s.realms().removeRealm(realmId); + } + + @Test + public void testOffset() { + withRealm(realmId, (session, realmModel) -> { + EventStoreProvider provider = session.getProvider(EventStoreProvider.class); + + Event e = new Event(); + e.setType(EventType.LOGIN); + e.setRealmId(realmId); + e.setTime(Time.currentTimeMillis()); + provider.onEvent(e); + return null; + }); + + withRealm(realmId, (session, realmModel) -> { + EventStoreProvider provider = session.getProvider(EventStoreProvider.class); + assertEquals(1, provider.createQuery().realm(realmId).getResultStream().count()); + + setTimeOffset(5); + + // store requires explicit expiration of expired events + ProviderFactory providerFactory = session.getKeycloakSessionFactory().getProviderFactory(EventStoreProvider.class); + if ("jpa".equals(providerFactory.getId())) { + provider.clearExpiredEvents(); + } + return null; + }); + + withRealm(realmId, (session, realmModel) -> { + EventStoreProvider provider = session.getProvider(EventStoreProvider.class); + assertEquals(0, provider.createQuery().realm(realmId).getResultStream().count()); + return null; + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/client/ClientModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/client/ClientModelTest.java new file mode 100644 index 00000000..f5cf77c9 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/client/ClientModelTest.java @@ -0,0 +1,196 @@ +package tech.ydb.keycloak.testsuite.client; + +import org.junit.Test; +import org.keycloak.models.*; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@RequireProvider(RealmProvider.class) +@RequireProvider(ClientProvider.class) +@RequireProvider(RoleProvider.class) +public class ClientModelTest extends KeycloakModelTest { + + private String realmId; + + private static final String searchClientId = "My ClIeNt WITH sP%Ces and sp*ci_l Ch***cters \" ?!"; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testClientsBasics() { + // Create client + ClientModel originalModel = withRealm(realmId, (session, realm) -> session.clients().addClient(realm, "myClientId")); + ClientModel searchClient = withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().addClient(realm, searchClientId); + client.setAlwaysDisplayInConsole(true); + client.addRedirectUri("http://www.redirecturi.com"); + return client; + }); + assertThat(originalModel.getId(), notNullValue()); + + // Find by id + { + ClientModel model = withRealm(realmId, (session, realm) -> session.clients().getClientById(realm, originalModel.getId())); + assertThat(model, notNullValue()); + assertThat(model.getId(), is(equalTo(model.getId()))); + assertThat(model.getClientId(), is(equalTo("myClientId"))); + } + + // Find by clientId + { + ClientModel model = withRealm(realmId, (session, realm) -> session.clients().getClientByClientId(realm, "myClientId")); + assertThat(model, notNullValue()); + assertThat(model.getId(), is(equalTo(originalModel.getId()))); + assertThat(model.getClientId(), is(equalTo("myClientId"))); + } + + // Search by clientId + { + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().searchClientsByClientIdStream(realm, "client with", 0, 10).findFirst().orElse(null); + assertThat(client, notNullValue()); + assertThat(client.getId(), is(equalTo(searchClient.getId()))); + assertThat(client.getClientId(), is(equalTo(searchClientId))); + return null; + }); + + + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().searchClientsByClientIdStream(realm, "sp*ci_l Ch***cters", 0, 10).findFirst().orElse(null); + assertThat(client, notNullValue()); + assertThat(client.getId(), is(equalTo(searchClient.getId()))); + assertThat(client.getClientId(), is(equalTo(searchClientId))); + return null; + }); + + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().searchClientsByClientIdStream(realm, " AND ", 0, 10).findFirst().orElse(null); + assertThat(client, notNullValue()); + assertThat(client.getId(), is(equalTo(searchClient.getId()))); + assertThat(client.getClientId(), is(equalTo(searchClientId))); + return null; + }); + + withRealm(realmId, (session, realm) -> { + // when searching by "%" all entries are expected + assertThat(session.clients().searchClientsByClientIdStream(realm, "%", 0, 10).count(), is(equalTo(2L))); + return null; + }); + } + + // using Boolean operand + { + Map> allRedirectUrisOfEnabledClients = withRealm(realmId, (session, realm) -> session.clients().getAllRedirectUrisOfEnabledClients(realm)); + assertThat(allRedirectUrisOfEnabledClients.values(), hasSize(1)); + assertThat(allRedirectUrisOfEnabledClients.keySet().iterator().next().getId(), is(equalTo(searchClient.getId()))); + } + + // Test storing flow binding override + { + // Add some override + withRealm(realmId, (session, realm) -> { + ClientModel clientById = session.clients().getClientById(realm, originalModel.getId()); + clientById.setAuthenticationFlowBindingOverride("browser", "customFlowId"); + return clientById; + }); + + String browser = withRealm(realmId, (session, realm) -> session.clients().getClientById(realm, originalModel.getId()).getAuthenticationFlowBindingOverride("browser")); + assertThat(browser, is(equalTo("customFlowId"))); + } + } + + @Test + public void testScopeMappingRoleRemoval() { + // create two clients, one realm role and one client role and assign both to one of the clients + inComittedTransaction(1, (session , i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + ClientModel client1 = session.clients().addClient(realm, "client1"); + ClientModel client2 = session.clients().addClient(realm, "client2"); + RoleModel realmRole = session.roles().addRealmRole(realm, "realm-role"); + RoleModel client2Role = session.roles().addClientRole(client2, "client2-role"); + client1.addScopeMapping(realmRole); + client1.addScopeMapping(client2Role); + return null; + }); + + // check everything is OK + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + final ClientModel client1 = session.clients().getClientByClientId(realm, "client1"); + assertThat(client1.getScopeMappingsStream().count(), is(2L)); + assertThat(client1.getScopeMappingsStream().filter(r -> r.getName().equals("realm-role")).count(), is(1L)); + assertThat(client1.getScopeMappingsStream().filter(r -> r.getName().equals("client2-role")).count(), is(1L)); + return null; + }); + + // remove the realm role + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + final RoleModel role = session.roles().getRealmRole(realm, "realm-role"); + session.roles().removeRole(role); + return null; + }); + + // check it is removed + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + final ClientModel client1 = session.clients().getClientByClientId(realm, "client1"); + assertThat(client1.getScopeMappingsStream().count(), is(1L)); + assertThat(client1.getScopeMappingsStream().filter(r -> r.getName().equals("client2-role")).count(), is(1L)); + return null; + }); + + // remove client role + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + final ClientModel client2 = session.clients().getClientByClientId(realm, "client2"); + final RoleModel role = session.roles().getClientRole(client2, "client2-role"); + session.roles().removeRole(role); + return null; + }); + + // check both clients are removed + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + final ClientModel client1 = session.clients().getClientByClientId(realm, "client1"); + assertThat(client1.getScopeMappingsStream().count(), is(0L)); + return null; + }); + + // remove clients + inComittedTransaction(1, (session , i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + final ClientModel client1 = session.clients().getClientByClientId(realm, "client1"); + final ClientModel client2 = session.clients().getClientByClientId(realm, "client2"); + session.clients().removeClient(realm, client1.getId()); + session.clients().removeClient(realm, client2.getId()); + return null; + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeModelTest.java new file mode 100644 index 00000000..2a545acc --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeModelTest.java @@ -0,0 +1,107 @@ + +package tech.ydb.keycloak.testsuite.clientscope; + +import org.hamcrest.Matchers; +import org.junit.Test; +import org.keycloak.models.*; +import org.keycloak.models.cache.CacheRealmProvider; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@RequireProvider(RealmProvider.class) +@RequireProvider(ClientProvider.class) +@RequireProvider(ClientScopeProvider.class) +@RequireProvider(RoleProvider.class) +public class ClientScopeModelTest extends KeycloakModelTest { + + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testClientScopes() { + List clientScopes = new LinkedList<>(); + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().addClient(realm, "myClientId"); + + ClientScopeModel clientScope1 = session.clientScopes().addClientScope(realm, "myClientScope1"); + clientScopes.add(clientScope1.getId()); + ClientScopeModel clientScope2 = session.clientScopes().addClientScope(realm, "myClientScope2"); + clientScopes.add(clientScope2.getId()); + + + client.addClientScope(clientScope1, true); + client.addClientScope(clientScope2, false); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + List actualClientScopes = session.clientScopes().getClientScopesStream(realm).map(ClientScopeModel::getId).collect(Collectors.toList()); + assertThat(actualClientScopes, containsInAnyOrder(clientScopes.toArray())); + + ClientScopeModel clientScopeById = session.clientScopes().getClientScopeById(realm, clientScopes.get(0)); + assertThat(clientScopeById.getId(), is(clientScopes.get(0))); + + session.clientScopes().removeClientScopes(realm); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + List actualClientScopes = session.clientScopes().getClientScopesStream(realm).collect(Collectors.toList()); + assertThat(actualClientScopes, empty()); + + return null; + }); + } + + @Test + @RequireProvider(value=ClientScopeProvider.class, only="ydb") + @RequireProvider(value=CacheRealmProvider.class) + public void testClientScopesCaching() { + List clientScopes = new LinkedList<>(); + withRealm(realmId, (session, realm) -> { + ClientScopeModel clientScope = session.clientScopes().addClientScope(realm, "myClientScopeForCaching"); + clientScopes.add(clientScope.getId()); + + assertionsForClientScopesCaching(clientScopes, session, realm); + return null; + }); + + withRealm(realmId, (session, realm) -> { + assertionsForClientScopesCaching(clientScopes, session, realm); + return null; + }); + + } + + private static void assertionsForClientScopesCaching(List clientScopes, KeycloakSession session, RealmModel realm) { + assertThat(clientScopes, Matchers.containsInAnyOrder(realm.getClientScopesStream() + .map(ClientScopeModel::getId).toArray(String[]::new))); + + assertThat(clientScopes, Matchers.containsInAnyOrder(session.clientScopes().getClientScopesStream(realm) + .map(ClientScopeModel::getId).toArray(String[]::new))); + } + +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/AdminEventQueryTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/AdminEventQueryTest.java new file mode 100644 index 00000000..d4d7fa81 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/AdminEventQueryTest.java @@ -0,0 +1,190 @@ +package tech.ydb.keycloak.testsuite.events; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.Test; +import org.keycloak.common.ClientConnection; +import org.keycloak.events.EventStoreProvider; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.events.admin.OperationType; +import org.keycloak.events.admin.ResourceType; +import org.keycloak.models.*; +import org.keycloak.models.delegate.ClientModelLazyDelegate; +import org.keycloak.models.utils.UserModelDelegate; +import org.keycloak.services.resources.admin.AdminAuth; +import org.keycloak.services.resources.admin.AdminEventBuilder; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@RequireProvider(EventStoreProvider.class) +public class AdminEventQueryTest extends KeycloakModelTest { + + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + EventStoreProvider eventStore = s.getProvider(EventStoreProvider.class); + eventStore.clearAdmin(s.realms().getRealm(realmId)); + s.realms().removeRealm(realmId); + } + + @Test + public void testQuery() { + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + eventStore.onEvent(createClientEvent(realm, session, OperationType.CREATE), false); + eventStore.onEvent(createClientEvent(realm, session, OperationType.UPDATE), false); + eventStore.onEvent(createClientEvent(realm, session, OperationType.DELETE), false); + eventStore.onEvent(createClientEvent(realm, session, OperationType.CREATE), false); + return null; + }); + + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + assertThat(eventStore.createAdminQuery() + .realm(realmId) + .firstResult(2) + .getResultStream() + .collect(Collectors.counting()), + is(2L)); + return null; + }); + } + + @Test + public void testQueryOrder() { + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + AdminEvent firstEvent = createClientEvent(realm, session, OperationType.CREATE); + firstEvent.setTime(1L); + AdminEvent secondEvent = createClientEvent(realm, session, OperationType.DELETE); + secondEvent.setTime(2L); + eventStore.onEvent(firstEvent, false); + eventStore.onEvent(secondEvent, false); + List adminEventsAsc = eventStore.createAdminQuery() + .realm(realmId) + .orderByAscTime() + .getResultStream() + .collect(Collectors.toList()); + assertThat(adminEventsAsc.size(), is(2)); + assertThat(adminEventsAsc.get(0).getOperationType(), is(OperationType.CREATE)); + assertThat(adminEventsAsc.get(1).getOperationType(), is(OperationType.DELETE)); + + List adminEventsDesc = eventStore.createAdminQuery() + .realm(realmId) + .orderByDescTime() + .getResultStream() + .collect(Collectors.toList()); + assertThat(adminEventsDesc.size(), is(2)); + assertThat(adminEventsDesc.get(0).getOperationType(), is(OperationType.DELETE)); + assertThat(adminEventsDesc.get(1).getOperationType(), is(OperationType.CREATE)); + return null; + }); + } + + @Test + public void testAdminEventRepresentationLongValue() { + String longValue = RandomStringUtils.random(30000, true, true); + + withRealm(realmId, (session, realm) -> { + + AdminEvent event = createClientEvent(realm, session, OperationType.CREATE); + event.setRepresentation(longValue); + + session.getProvider(EventStoreProvider.class).onEvent(event, true); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + List events = session.getProvider(EventStoreProvider.class).createAdminQuery().realm(realmId).getResultStream().collect(Collectors.toList()); + assertThat(events, hasSize(1)); + + assertThat(events.get(0).getRepresentation(), equalTo(longValue)); + + return null; + }); + } + + private AdminEvent createClientEvent(RealmModel realm, KeycloakSession session, OperationType operation) { + return new AdminEventBuilder(realm, new DummyAuth(realm), session, DummyClientConnection.DUMMY_CONNECTION) + .resource(ResourceType.CLIENT).operation(operation).getEvent(); + } + + private static class DummyClientConnection implements ClientConnection { + + private static final DummyClientConnection DUMMY_CONNECTION = + new DummyClientConnection(); + + @Override + public String getRemoteAddr() { + return "remoteAddr"; + } + + @Override + public String getRemoteHost() { + return "remoteHost"; + } + + @Override + public int getRemotePort() { + return -1; + } + + @Override + public String getLocalAddr() { + return "localAddr"; + } + + @Override + public int getLocalPort() { + return -2; + } + } + + private static class DummyAuth extends AdminAuth { + + private final RealmModel realm; + + public DummyAuth(RealmModel realm) { + super(realm, null, null, null); + this.realm = realm; + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public ClientModel getClient() { + return new ClientModelLazyDelegate.WithId("dummy-client", null); + } + + @Override + public UserModel getUser() { + return new UserModelDelegate(null) { + @Override + public String getId() { + return "dummy-user"; + } + }; + } + } + +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/EventQueryTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/EventQueryTest.java new file mode 100644 index 00000000..939c8f1b --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/EventQueryTest.java @@ -0,0 +1,186 @@ +package tech.ydb.keycloak.testsuite.events; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.Test; +import org.keycloak.common.ClientConnection; +import org.keycloak.events.Event; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventStoreProvider; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@RequireProvider(EventStoreProvider.class) +public class EventQueryTest extends KeycloakModelTest { + + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testClear() { + inRolledBackTransaction(null, (session, t) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + eventStore.clear(); + }); + } + + private Event createAuthEventForUser(KeycloakSession session, RealmModel realm, String user) { + return new EventBuilder(realm, session, DummyClientConnection.DUMMY_CONNECTION) + .event(EventType.LOGIN) + .user(user) + .getEvent(); + } + + @Test + public void testQuery() { + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + + eventStore.onEvent(createAuthEventForUser(session, realm, "u1")); + eventStore.onEvent(createAuthEventForUser(session, realm, "u2")); + eventStore.onEvent(createAuthEventForUser(session, realm, "u3")); + eventStore.onEvent(createAuthEventForUser(session, realm, "u4")); + + return realm.getId(); + }); + + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + assertThat(eventStore.createQuery() + .realm(realmId) + .firstResult(2) + .getResultStream() + .collect(Collectors.counting()), + is(2L) + ); + + return null; + }); + } + + @Test + public void testQueryOrder() { + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + + Event firstEvent = createAuthEventForUser(session, realm, "u1"); + firstEvent.setTime(1L); + Event secondEvent = createAuthEventForUser(session, realm, "u2"); + secondEvent.setTime(2L); + eventStore.onEvent(firstEvent); + eventStore.onEvent(secondEvent); + + return realm.getId(); + }); + + withRealm(realmId, (session, realm) -> { + EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); + List eventsAsc = eventStore.createQuery() + .realm(realmId) + .orderByAscTime() + .getResultStream() + .collect(Collectors.toList()); + assertThat(eventsAsc.size(), is(2)); + assertThat(eventsAsc.get(0).getUserId(), is("u1")); + assertThat(eventsAsc.get(1).getUserId(), is("u2")); + + List eventsDesc = eventStore.createQuery() + .realm(realmId) + .orderByDescTime() + .getResultStream() + .collect(Collectors.toList()); + assertThat(eventsDesc.size(), is(2)); + assertThat(eventsDesc.get(0).getUserId(), is("u2")); + assertThat(eventsDesc.get(1).getUserId(), is("u1")); + + return null; + }); + } + + @Test + public void testEventDetailsLongValue() { + String v1 = RandomStringUtils.random(1000, true, true); + String v2 = RandomStringUtils.random(1000, true, true); + String v3 = RandomStringUtils.random(1000, true, true); + String v4 = RandomStringUtils.random(1000, true, true); + + withRealm(realmId, (session, realm) -> { + + Map details = Map.of("k1", v1, "k2", v2, "k3", v3, "k4", v4); + + Event event = createAuthEventForUser(session, realm, "u1"); + event.setDetails(details); + + session.getProvider(EventStoreProvider.class).onEvent(event); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + List events = session.getProvider(EventStoreProvider.class).createQuery().realm(realmId).getResultStream().collect(Collectors.toList()); + assertThat(events, hasSize(1)); + Map details = events.get(0).getDetails(); + + assertThat(details.get("k1"), equalTo(v1)); + assertThat(details.get("k2"), equalTo(v2)); + assertThat(details.get("k3"), equalTo(v3)); + assertThat(details.get("k4"), equalTo(v4)); + return null; + }); + } + + private static class DummyClientConnection implements ClientConnection { + + private static DummyClientConnection DUMMY_CONNECTION = new DummyClientConnection(); + + @Override + public String getRemoteAddr() { + return "remoteAddr"; + } + + @Override + public String getRemoteHost() { + return "remoteHost"; + } + + @Override + public int getRemotePort() { + return -1; + } + + @Override + public String getLocalAddr() { + return "localAddr"; + } + + @Override + public int getLocalPort() { + return -2; + } + } + +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java new file mode 100644 index 00000000..a44bd528 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java @@ -0,0 +1,210 @@ +package tech.ydb.keycloak.testsuite.group; + +import org.junit.Test; +import org.keycloak.models.Constants; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +public class GroupModelTest extends KeycloakModelTest { + + private String realmId; + private static final String OLD_VALUE = "oldValue"; + private static final String NEW_VALUE = "newValue"; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testGroupAttributesSetter() { + String groupId = withRealm(realmId, (session, realm) -> { + GroupModel groupModel = session.groups().createGroup(realm, "my-group"); + groupModel.setSingleAttribute("key", OLD_VALUE); + + return groupModel.getId(); + }); + withRealm(realmId, (session, realm) -> { + GroupModel groupModel = session.groups().getGroupById(realm, groupId); + assertThat(groupModel.getAttributes().get("key"), contains(OLD_VALUE)); + + // Change value to NEW_VALUE + groupModel.setSingleAttribute("key", NEW_VALUE); + + // Check all getters return the new value + assertThat(groupModel.getAttributes().get("key"), contains(NEW_VALUE)); + assertThat(groupModel.getFirstAttribute("key"), equalTo(NEW_VALUE)); + assertThat(groupModel.getAttributeStream("key").findFirst().get(), equalTo(NEW_VALUE)); + + return null; + }); + } + + @Test + public void testSubGroupsSorted() { + List subGroups = Arrays.asList("sub-group-1", "sub-group-2", "sub-group-3"); + + String groupId = withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().createGroup(realm, "my-group"); + + subGroups.stream().sorted(Collections.reverseOrder()).forEach(s -> { + GroupModel subGroup = session.groups().createGroup(realm, s); + group.addChild(subGroup); + }); + + return group.getId(); + }); + withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().getGroupById(realm, groupId); + + assertThat(group.getSubGroupsStream().map(GroupModel::getName).collect(Collectors.toList()), + contains(subGroups.toArray())); + + return null; + }); + } + + @Test + public void testGroupByName() { + String subGroupId1 = withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().createGroup(realm, "parent-1"); + GroupModel subGroup = session.groups().createGroup(realm, "sub-group-1", group); + return subGroup.getId(); + }); + + String subGroupId2 = withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().createGroup(realm, "parent-2"); + GroupModel subGroup = session.groups().createGroup(realm, "sub-group-1", group); + return subGroup.getId(); + }); + withRealm(realmId, (session, realm) -> { + GroupModel group1 = session.groups().getGroupByName(realm, null,"parent-1"); + GroupModel group2 = session.groups().getGroupByName(realm, null,"parent-2"); + + GroupModel subGroup1 = session.groups().getGroupByName(realm, group1,"sub-group-1"); + GroupModel subGroup2 = session.groups().getGroupByName(realm, group2,"sub-group-1"); + + assertThat(subGroup1.getId(), equalTo(subGroupId1)); + assertThat(subGroup1.getName(), equalTo("sub-group-1")); + assertThat(subGroup2.getId(), equalTo(subGroupId2)); + assertThat(subGroup2.getName(), equalTo("sub-group-1")); + return null; + }); + } + + @Test + public void testConflictingNames() { + final String conflictingGroupName = "conflicting-group-name"; + + String parentGroupWithChildId = withRealm(realmId, (session, realm) -> { + GroupModel parentGroupWithChild = session.groups().createGroup(realm, "parent-1"); + GroupModel subGroup1 = session.groups().createGroup(realm, conflictingGroupName, parentGroupWithChild); + return parentGroupWithChild.getId(); + }); + + String parentGroupWithConflictingNameId = withRealm(realmId, (session, realm) -> session.groups().createGroup(realm, conflictingGroupName).getId()); + String parentGroupWithoutChildrenId = withRealm(realmId, (session, realm) -> session.groups().createGroup(realm, "parent-2").getId()); + + withRealm(realmId, (session, realm) -> { + GroupModel searchedGroup = session.groups().getGroupByName(realm, null, conflictingGroupName); + assertThat(searchedGroup, notNullValue()); + assertThat(searchedGroup.getId(), equalTo(parentGroupWithConflictingNameId)); + return null; + }); + + withRealm(realmId, (session, realm) -> { + GroupModel parentGroupWithChild = session.groups().getGroupById(realm, parentGroupWithChildId); + GroupModel searchedGroup = session.groups().getGroupByName(realm, parentGroupWithChild, conflictingGroupName); + assertThat(searchedGroup, notNullValue()); + assertThat(searchedGroup.getParentId(), equalTo(parentGroupWithChildId)); + return null; + }); + + withRealm(realmId, (session, realm) -> { + GroupModel parentGroupWithoutChildren = session.groups().getGroupById(realm, parentGroupWithoutChildrenId); + GroupModel searchedGroup = session.groups().getGroupByName(realm, parentGroupWithoutChildren, conflictingGroupName); + assertThat(searchedGroup, nullValue()); + return null; + }); + } + + @Test + public void testGroupByNameCacheInvalidation() { + String subGroupId1 = withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().createGroup(realm, "parent-1"); + GroupModel subGroup = session.groups().createGroup(realm, "sub-group-1", group); + return subGroup.getId(); + }); + + withRealm(realmId, (session, realm) -> { + GroupModel group1 = session.groups().getGroupByName(realm, null, "parent-1"); + GroupModel subGroup1 = session.groups().getGroupByName(realm, group1, "sub-group-1"); + assertThat(subGroup1.getId(), equalTo(subGroupId1)); + return null; + }); + + withRealm(realmId, (session, realm) -> { + GroupModel group1 = session.groups().getGroupByName(realm, null, "parent-1"); + GroupModel subGroup1 = session.groups().getGroupByName(realm, group1, "sub-group-1"); + session.groups().removeGroup(realm, subGroup1); + return null; + }); + + withRealm(realmId, (session, realm) -> { + GroupModel group1 = session.groups().getGroupByName(realm, null, "parent-1"); + GroupModel subGroup1 = session.groups().getGroupByName(realm, group1, "sub-group-1"); + assertThat(subGroup1, nullValue()); + return null; + }); + + } + @Test + public void testFindGroupByPath() { + String subGroupId1 = withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().createGroup(realm, "parent-1"); + GroupModel subGroup = session.groups().createGroup(realm, "sub-group-1", group); + return subGroup.getId(); + }); + + String subGroupIdWithSlash = withRealm(realmId, (session, realm) -> { + GroupModel group = session.groups().createGroup(realm, "parent-2"); + GroupModel subGroup = session.groups().createGroup(realm, "sub-group/1", group); + return subGroup.getId(); + }); + + withRealm(realmId, (session, realm) -> { + GroupModel group1 = KeycloakModelUtils.findGroupByPath(session, realm, "/parent-1"); + GroupModel group2 = KeycloakModelUtils.findGroupByPath(session, realm, "/parent-2"); + assertThat(group1.getName(), equalTo("parent-1")); + assertThat(group2.getName(), equalTo("parent-2")); + + GroupModel subGroup1 = KeycloakModelUtils.findGroupByPath(session, realm, "/parent-1/sub-group-1"); + GroupModel subGroup2 = KeycloakModelUtils.findGroupByPath(session, realm, "/parent-2/sub-group/1"); + assertThat(subGroup1.getId(), equalTo(subGroupId1)); + assertThat(subGroup2.getId(), equalTo(subGroupIdWithSlash)); + return null; + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Infinispan.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Infinispan.java new file mode 100644 index 00000000..ff46e39b --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Infinispan.java @@ -0,0 +1,113 @@ +package tech.ydb.keycloak.testsuite.parameters; + +import com.google.common.collect.ImmutableSet; +import org.keycloak.cluster.infinispan.InfinispanClusterProviderFactory; +import org.keycloak.connections.infinispan.InfinispanConnectionProviderFactory; +import org.keycloak.connections.infinispan.InfinispanConnectionSpi; +import org.keycloak.infinispan.util.InfinispanUtils; +import org.keycloak.keys.PublicKeyStorageSpi; +import org.keycloak.keys.infinispan.InfinispanCachePublicKeyProviderFactory; +import org.keycloak.keys.infinispan.InfinispanPublicKeyStorageProviderFactory; +import org.keycloak.models.SingleUseObjectSpi; +import org.keycloak.models.UserLoginFailureSpi; +import org.keycloak.models.UserSessionSpi; +import org.keycloak.models.cache.CachePublicKeyProviderSpi; +import org.keycloak.models.cache.CacheRealmProviderSpi; +import org.keycloak.models.cache.CacheUserProviderSpi; +import org.keycloak.models.cache.authorization.CachedStoreFactorySpi; +import org.keycloak.models.cache.infinispan.InfinispanCacheRealmProviderFactory; +import org.keycloak.models.cache.infinispan.InfinispanUserCacheProviderFactory; +import org.keycloak.models.cache.infinispan.authorization.InfinispanCacheStoreFactoryProviderFactory; +import org.keycloak.models.cache.infinispan.organization.InfinispanOrganizationProviderFactory; +import org.keycloak.models.session.UserSessionPersisterSpi; +import org.keycloak.models.sessions.infinispan.InfinispanAuthenticationSessionProviderFactory; +import org.keycloak.models.sessions.infinispan.InfinispanSingleUseObjectProviderFactory; +import org.keycloak.models.sessions.infinispan.InfinispanUserLoginFailureProviderFactory; +import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProviderFactory; +import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionProviderFactory; +import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionSpi; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; +import org.keycloak.sessions.AuthenticationSessionSpi; +import org.keycloak.sessions.StickySessionEncoderProviderFactory; +import org.keycloak.sessions.StickySessionEncoderSpi; +import org.keycloak.spi.infinispan.CacheEmbeddedConfigProviderFactory; +import org.keycloak.spi.infinispan.CacheEmbeddedConfigProviderSpi; +import org.keycloak.spi.infinispan.JGroupsCertificateProviderFactory; +import org.keycloak.spi.infinispan.JGroupsCertificateProviderSpi; +import org.keycloak.spi.infinispan.impl.embedded.DefaultCacheEmbeddedConfigProviderFactory; +import org.keycloak.storage.configuration.ServerConfigStorageProviderFactory; +import org.keycloak.storage.configuration.ServerConfigurationStorageProviderSpi; +import org.keycloak.timer.TimerProviderFactory; +import tech.ydb.keycloak.testsuite.Config; +import tech.ydb.keycloak.testsuite.KeycloakModelParameters; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +public class Infinispan extends KeycloakModelParameters { + + private static final AtomicInteger NODE_COUNTER = new AtomicInteger(); + + static final Set> ALLOWED_SPIS = ImmutableSet.>builder() + .add(AuthenticationSessionSpi.class) + .add(CacheRealmProviderSpi.class) + .add(CachedStoreFactorySpi.class) + .add(CacheUserProviderSpi.class) + .add(InfinispanConnectionSpi.class) + .add(StickySessionEncoderSpi.class) + .add(UserSessionPersisterSpi.class) + .add(SingleUseObjectSpi.class) + .add(PublicKeyStorageSpi.class) + .add(CachePublicKeyProviderSpi.class) + .add(CacheEmbeddedConfigProviderSpi.class) + .add(JGroupsCertificateProviderSpi.class) + .add(ServerConfigurationStorageProviderSpi.class) + .add(InfinispanTransactionSpi.class) + .build(); + + static final Set> ALLOWED_FACTORIES = ImmutableSet.>builder() + .add(InfinispanAuthenticationSessionProviderFactory.class) + .add(InfinispanCacheRealmProviderFactory.class) + .add(InfinispanCacheStoreFactoryProviderFactory.class) + .add(InfinispanClusterProviderFactory.class) + .add(InfinispanConnectionProviderFactory.class) + .add(InfinispanUserCacheProviderFactory.class) + .add(InfinispanUserSessionProviderFactory.class) + .add(InfinispanUserLoginFailureProviderFactory.class) + .add(InfinispanSingleUseObjectProviderFactory.class) + .add(StickySessionEncoderProviderFactory.class) + .add(TimerProviderFactory.class) + .add(InfinispanPublicKeyStorageProviderFactory.class) + .add(InfinispanCachePublicKeyProviderFactory.class) + .add(InfinispanOrganizationProviderFactory.class) + .add(CacheEmbeddedConfigProviderFactory.class) + .add(JGroupsCertificateProviderFactory.class) + .add(ServerConfigStorageProviderFactory.class) + .add(InfinispanTransactionProviderFactory.class) + .build(); + + @Override + public void updateConfig(Config cf) { + cf.spi("connectionsInfinispan") + .provider("default") + .config("useKeycloakTimeService", "true") + .spi(UserLoginFailureSpi.NAME) + .provider(InfinispanUtils.EMBEDDED_PROVIDER_ID) + .config("stalledTimeoutInSeconds", "10") + .spi(UserSessionSpi.NAME) + .provider(InfinispanUtils.EMBEDDED_PROVIDER_ID) + .config("sessionPreloadStalledTimeoutInSeconds", "10") + .config("offlineSessionCacheEntryLifespanOverride", "43200") + .config("offlineClientSessionCacheEntryLifespanOverride", "43200"); + cf.spi(CacheEmbeddedConfigProviderSpi.SPI_NAME) + .provider(DefaultCacheEmbeddedConfigProviderFactory.PROVIDER_ID) + .config(DefaultCacheEmbeddedConfigProviderFactory.CONFIG, "test-ispn.xml") + .config(DefaultCacheEmbeddedConfigProviderFactory.NODE_NAME, "node-" + NODE_COUNTER.incrementAndGet()); + + } + + public Infinispan() { + super(ALLOWED_SPIS, ALLOWED_FACTORIES); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java new file mode 100644 index 00000000..a426196a --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java @@ -0,0 +1,160 @@ +package tech.ydb.keycloak.testsuite.parameters; + +import com.google.common.collect.ImmutableSet; +import org.jboss.logging.Logger; +import org.keycloak.authorization.jpa.store.JPAAuthorizationStoreFactory; +import org.keycloak.broker.provider.IdentityProviderFactory; +import org.keycloak.broker.provider.IdentityProviderSpi; +import org.keycloak.connections.jpa.DefaultJpaConnectionProviderFactory; +import org.keycloak.connections.jpa.JpaConnectionSpi; +import org.keycloak.connections.jpa.updater.JpaUpdaterProviderFactory; +import org.keycloak.connections.jpa.updater.JpaUpdaterSpi; +import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory; +import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionSpi; +import org.keycloak.connections.jpa.updater.liquibase.lock.LiquibaseDBLockProviderFactory; +import org.keycloak.events.jpa.JpaEventStoreProviderFactory; +import org.keycloak.migration.MigrationProviderFactory; +import org.keycloak.migration.MigrationSpi; +import org.keycloak.models.IdentityProviderStorageSpi; +import org.keycloak.models.dblock.DBLockSpi; +import org.keycloak.models.jpa.JpaGroupProviderFactory; +import org.keycloak.models.jpa.JpaIdentityProviderStorageProviderFactory; +import org.keycloak.models.jpa.JpaRoleProviderFactory; +import org.keycloak.models.jpa.JpaUserProviderFactory; +import org.keycloak.models.jpa.session.JpaRevokedTokensPersisterProviderFactory; +import org.keycloak.models.jpa.session.JpaUserSessionPersisterProviderFactory; +import org.keycloak.models.session.RevokedTokenPersisterSpi; +import org.keycloak.models.session.UserSessionPersisterSpi; +import org.keycloak.organization.OrganizationSpi; +import org.keycloak.organization.jpa.JpaOrganizationProviderFactory; +import org.keycloak.protocol.LoginProtocolFactory; +import org.keycloak.protocol.LoginProtocolSpi; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; +import org.keycloak.storage.DatastoreSpi; +import org.keycloak.storage.datastore.DefaultDatastoreProviderFactory; +import tech.ydb.keycloak.client.YdbClientProviderFactory; +import tech.ydb.keycloak.client.YdbClientScopeProviderFactory; +import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl; +import tech.ydb.keycloak.liquibase.YdbDBLockProviderFactory; +import tech.ydb.keycloak.liquibase.YdbLiquibaseConnectionProvider; +import tech.ydb.keycloak.realm.YdbRealmProviderFactory; +import tech.ydb.keycloak.testsuite.Config; +import tech.ydb.keycloak.testsuite.KeycloakModelParameters; +import tech.ydb.test.integration.YdbHelper; +import tech.ydb.test.integration.YdbHelperFactory; + +import java.util.Set; + +import static tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID; + +public class Ydb extends KeycloakModelParameters { + + private static final Logger LOG = Logger.getLogger(Ydb.class); + + static final Set> ALLOWED_SPIS = ImmutableSet.>builder() + .add(JpaConnectionSpi.class) + .add(JpaUpdaterSpi.class) + .add(LiquibaseConnectionSpi.class) + .add(UserSessionPersisterSpi.class) + .add(RevokedTokenPersisterSpi.class) + .add(DatastoreSpi.class) + .add(MigrationSpi.class) + .add(LoginProtocolSpi.class) + .add(DBLockSpi.class) + .add(IdentityProviderStorageSpi.class) + .add(IdentityProviderSpi.class) + .add(OrganizationSpi.class) + .build(); + + /** + * JPA providers used where YDB implementation is not yet available + */ + static final Set> ALLOWED_FACTORIES = + ImmutableSet.>builder() + .add(DefaultDatastoreProviderFactory.class) + .add(YdbConnectionProviderFactoryImpl.class) + .add(DefaultJpaConnectionProviderFactory.class) + .add(JPAAuthorizationStoreFactory.class) + .add(YdbClientProviderFactory.class) + .add(YdbClientScopeProviderFactory.class) + .add(JpaEventStoreProviderFactory.class) + .add(JpaGroupProviderFactory.class) + .add(JpaIdentityProviderStorageProviderFactory.class) + .add(YdbRealmProviderFactory.class) + .add(JpaRoleProviderFactory.class) + .add(JpaUpdaterProviderFactory.class) + .add(JpaUserProviderFactory.class) + .add(YdbLiquibaseConnectionProvider.class) + .add(YdbDBLockProviderFactory.class) + .add(JpaUserSessionPersisterProviderFactory.class) + .add(JpaRevokedTokensPersisterProviderFactory.class) + .add(MigrationProviderFactory.class) + .add(LoginProtocolFactory.class) + .add(IdentityProviderFactory.class) + .add(JpaOrganizationProviderFactory.class) + .build(); + + private YdbHelper ydbHelper; + + public Ydb() { + super(ALLOWED_SPIS, ALLOWED_FACTORIES); + } + + @Override + public void beforeSuite(Config cf) { + YdbHelperFactory factory = YdbHelperFactory.getInstance(); + if (!factory.isEnabled()) { + LOG.warn("YDB helper is not available - tests will be skipped"); + return; + } + LOG.info("Creating YDB helper for Keycloak model tests"); + ydbHelper = factory.createHelper(); + if (ydbHelper == null) { + LOG.warn("Failed to create YDB helper - tests will be skipped"); + } + } + + @Override + public void afterSuite() { + if (ydbHelper != null) { + try { + ydbHelper.close(); + } catch (Exception e) { + LOG.warnf(e, "Error closing YDB helper"); + } + ydbHelper = null; + } + } + + @Override + public void updateConfig(Config cf) { + // Client and realm: YDB. Other SPIs below: JPA until YDB provider exists + cf.spi("client").defaultProvider(PROVIDER_ID) + .spi("clientScope").defaultProvider(PROVIDER_ID) + .spi("realm").defaultProvider(PROVIDER_ID) + .spi("connectionsLiquibase").defaultProvider(PROVIDER_ID) + .spi("dblock").defaultProvider(PROVIDER_ID) + .spi("group").defaultProvider("jpa") + .spi("idp").defaultProvider("jpa") + .spi("role").defaultProvider("jpa") + .spi("user").defaultProvider("jpa") + .spi("deploymentState").defaultProvider("jpa"); + + // YDB connection - use ydb provider with jdbcUrl from YdbHelper + if (ydbHelper != null) { + String jdbcUrl = buildJdbcUrl(ydbHelper); + cf.spi("connectionsJpa") + .defaultProvider(PROVIDER_ID) + .provider(PROVIDER_ID) + .config("jdbcUrl", jdbcUrl); + } + } + + private static String buildJdbcUrl(YdbHelper helper) { + return "jdbc:ydb:" + + (helper.useTls() ? "grpcs://" : "grpc://") + + helper.endpoint() + + helper.database(); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java new file mode 100644 index 00000000..427fd468 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java @@ -0,0 +1,380 @@ +package tech.ydb.keycloak.testsuite.role; + +import org.hamcrest.Matcher; +import org.junit.Test; +import org.keycloak.models.*; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.Collection; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@RequireProvider(RealmProvider.class) +@RequireProvider(ClientProvider.class) +@RequireProvider(RoleProvider.class) +public class RoleModelTest extends KeycloakModelTest { + + private static final String MAIN_ROLE_NAME = "main-role"; + private static final String ROLE_PREFIX = "main-role-composite-"; + private static final String CLIENT_NAME = "client-with-roles"; + + private String realmId; + private String mainRoleId; + private static List rolesSubset; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + + createRoles(s, realm); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @FunctionalInterface + public interface GetResult { + List getResult(String search, Integer first, Integer max); + } + + + private void createRoles(KeycloakSession session, RealmModel realm) { + RoleModel mainRole = session.roles().addRealmRole(realm, MAIN_ROLE_NAME); + mainRoleId = mainRole.getId(); + + ClientModel clientModel = session.clients().addClient(realm, CLIENT_NAME); + + // Create 10 realm roles that are composites of main role + rolesSubset = IntStream.range(0, 10) + .boxed() + .map(i -> session.roles().addRealmRole(realm, ROLE_PREFIX + i)) + .peek(role -> role.setDescription("This is a description for " + role.getName() + " realm role.")) + .peek(mainRole::addCompositeRole) + .map(RoleModel::getId) + .collect(Collectors.toList()); + + // Create 10 client roles that are composites of main role + rolesSubset.addAll(IntStream.range(10, 20) + .boxed() + .map(i -> session.roles().addClientRole(clientModel, ROLE_PREFIX + i)) + .peek(role -> role.setDescription("This is a description for " + role.getName() + " client role.")) + .peek(mainRole::addCompositeRole) + .map(RoleModel::getId) + .collect(Collectors.toList())); + + // add some additional roles that won't fulfill condition + IntStream.range(0, 20) + .forEach(i -> session.roles().addRealmRole(realm, "non-returned-role-" + i)); + } + + private List getResult(String search, Integer first, Integer max) { + return withRealm(realmId, (session, realm) -> session.roles().getRolesStream(realm, rolesSubset.stream(), search, first, max).collect(Collectors.toList())); + } + + private RoleModel getMainRole() { + return withRealm(realmId, (session, realm) -> session.roles().getRoleById(realm, mainRoleId)); + } + + private List getModelResult(String search, Integer first, Integer max) { + return withRealm(realmId, ((session, realm) -> session.roles().getRoleById(realm, mainRoleId).getCompositesStream(search, first, max).collect(Collectors.toList()))); + } + + @Test + public void testRolesWithIdsQueries() { + // should return all roles from the subset + List result = getResult(null, null, null); + assertThat(result, hasSize(rolesSubset.size())); + assertIndexValues(result, contains(0, 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + + // test non-existing role ids + result = withRealm(realmId, (session, realm) -> session.roles() + .getRolesStream(realm, IntStream.range(0, 10).boxed() + .map(i -> UUID.randomUUID().toString()), null, null, null) + .collect(Collectors.toList())); + assertThat(result, is(empty())); + + // test mixed non-existing with existing + result = withRealm(realmId, (session, realm) -> session.roles() + .getRolesStream(realm, Stream.concat(rolesSubset.subList(0, 10).stream(), + IntStream.range(0, 10).boxed().map(i -> UUID.randomUUID().toString())), null, null, null) + .collect(Collectors.toList())); + assertThat(result, hasSize(10)); + assertIndexValues(result, contains(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)); + } + + @Test + public void testCompositeRoles() { + List result = getModelResult(null, null, null); + assertThat(result, hasSize(rolesSubset.size())); + assertIndexValues(result, contains(0, 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + + result = withRealm(realmId, (session, realm) -> session.roles().getRoleById(realm, mainRoleId).getCompositesStream().collect(Collectors.toList())); + assertThat(result, hasSize(rolesSubset.size())); + assertIndexValues(result, containsInAnyOrder(0, 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + } + + @Test + public void testRolesWithIdsSearchQueries() { + testRolesWithIdsSearchQueries(this::getResult); + } + + @Test + public void testCompositeRolesSearchQueries() { + testRolesWithIdsSearchQueries(this::getModelResult); + } + + public void testRolesWithIdsSearchQueries(GetResult resultProvider) { + // should return all roles from the subset + List result = resultProvider.getResult("", null, null); + assertThat(result, hasSize(rolesSubset.size())); + assertIndexValues(result, contains(0, 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + + // test string that all contains + result = resultProvider.getResult("role-composite", null, null); + assertThat(result, hasSize(rolesSubset.size())); + assertIndexValues(result, contains(0, 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + + // test string that some contain + result = resultProvider.getResult("role-composite-1", null, null); + assertThat(result, hasSize(11)); + assertIndexValues(result, contains(1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19)); + + // test string none contain + result = resultProvider.getResult("nonsense-string", null, null); + assertThat(result, is(empty())); + } + + @Test + public void testRolesWithIdsPaginationQueries() { + testRolesWithIdsPaginationQueries(this::getResult); + } + + @Test + public void testCompositeRolesPaginationQueries() { + testRolesWithIdsPaginationQueries(this::getResult); + } + + public void testRolesWithIdsPaginationQueries(GetResult resultProvider) { + // should return all roles from the subset + List result = resultProvider.getResult(null, null, rolesSubset.size()); + assertThat(result, hasSize(rolesSubset.size())); + assertIndexValues(result, contains(0, 1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + + // test max parameter + result = resultProvider.getResult(null, null, 5); + assertThat(result, hasSize(5)); + assertIndexValues(result, contains(0, 1, 10, 11, 12)); + + // test first parameter + result = resultProvider.getResult(null, 10, null); + assertThat(result, hasSize(rolesSubset.size() - 10)); + assertIndexValues(result, contains(18, 19, 2, 3, 4, 5, 6, 7, 8, 9)); + + // test first and max + result = resultProvider.getResult(null, 10, 5); + assertThat(result, hasSize(5)); + assertIndexValues(result, contains(18, 19, 2, 3, 4)); + } + + @Test + public void testRolesWithIdsPaginationSearchQueries() { + testRolesWithIdsPaginationSearchQueries(this::getResult); + } + + @Test + public void testCompositeRolesPaginationSearchQueries() { + testRolesWithIdsPaginationSearchQueries(this::getModelResult); + } + + @Test + public void testSearchRolesByDescription() { + withRealm(realmId, (session, realm) -> { + List realmRolesByDescription = session.roles().searchForRolesStream(realm, "This is a", null, null).collect(Collectors.toList()); + assertThat(realmRolesByDescription, hasSize(10)); + realmRolesByDescription = session.roles().searchForRolesStream(realm, "realm role.", 5, null).collect(Collectors.toList()); + assertThat(realmRolesByDescription, hasSize(5)); + realmRolesByDescription = session.roles().searchForRolesStream(realm, "DESCRIPTION FOR", 3, 9).collect(Collectors.toList()); + assertThat(realmRolesByDescription, hasSize(7)); + + ClientModel client = session.clients().getClientByClientId(realm, CLIENT_NAME); + + List clientRolesByDescription = session.roles().searchForClientRolesStream(client, "this is a", 0, 10).collect(Collectors.toList()); + assertThat(clientRolesByDescription, hasSize(10)); + + clientRolesByDescription = session.roles().searchForClientRolesStream(client, "role-composite-13 client role", null, null).collect(Collectors.toList()); + assertThat(clientRolesByDescription, hasSize(1)); + assertThat(clientRolesByDescription.get(0).getDescription(), is("This is a description for main-role-composite-13 client role.")); + + return null; + }); + } + + @Test + public void testCompositeRolesUpdateOnChildRoleRemoval() { + final AtomicReference parentRealmRoleId = new AtomicReference<>(); + final AtomicReference parentClientRoleId = new AtomicReference<>(); + + final AtomicReference childRealmRoleId = new AtomicReference<>(); + final AtomicReference childClientRoleId = new AtomicReference<>(); + + withRealm(realmId, (session, realm) -> { + // Create realm role + RoleModel parentRealmRole = session.roles().addRealmRole(realm, "parentRealmRole"); + parentRealmRoleId.set(parentRealmRole.getId()); + + // Create client role + ClientModel client = session.clients().addClient(realm,"clientWithRole"); + + RoleModel parentClientRole = session.roles().addClientRole(client, "parentClientRole"); + parentClientRoleId.set(parentClientRole.getId()); + + // Create realm child role + RoleModel childRealmRole = session.roles().addRealmRole(realm, "childRealmRole"); + childRealmRoleId.set(childRealmRole.getId()); + + RoleModel childClientRole = session.roles().addClientRole(client, "childClientRole"); + childClientRoleId.set(childClientRole.getId()); + + // Add composites + parentRealmRole.addCompositeRole(childRealmRole); + parentRealmRole.addCompositeRole(childClientRole); + + parentClientRole.addCompositeRole(childRealmRole); + parentClientRole.addCompositeRole(childClientRole); + return null; + }); + + withRealm(realmId, (session, realm) -> { + RoleModel parentRealmRole = session.roles().getRoleById(realm, parentRealmRoleId.get()); + RoleModel parentClientRole = session.roles().getRoleById(realm, parentClientRoleId.get()); + assertThat(parentRealmRole.getCompositesStream().collect(Collectors.toSet()), hasSize(2)); + assertThat(parentClientRole.getCompositesStream().collect(Collectors.toSet()), hasSize(2)); + + session.roles().removeRole(session.roles().getRoleById(realm, childRealmRoleId.get())); + session.roles().removeRole(session.roles().getRoleById(realm, childClientRoleId.get())); + return null; + }); + + withRealm(realmId, (session, realm) -> { + RoleModel parentRealmRole = session.roles().getRoleById(realm, parentRealmRoleId.get()); + RoleModel parentClientRole = session.roles().getRoleById(realm, parentClientRoleId.get()); + assertThat(parentRealmRole.getCompositesStream().collect(Collectors.toSet()), empty()); + assertThat(parentClientRole.getCompositesStream().collect(Collectors.toSet()), empty()); + return null; + }); + } + + @Test + public void getRoleByNameFromTheDatabaseAndTheCache() { + String roleName = "role-" + new Random().nextInt(); + + // Look up a non-existent role from the database + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().getRealmRole(realm, roleName); + assertThat(role, nullValue()); + return null; + }); + + // Look up a non-existent role from the cache + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().getRealmRole(realm, roleName); + assertThat(role, nullValue()); + return null; + }); + + // Create the role, and invalidate the cache + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().addRealmRole(realm, roleName); + assertThat(role, notNullValue()); + return null; + }); + + // Find the role from the database + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().getRealmRole(realm, roleName); + assertThat(role, notNullValue()); + return null; + }); + + // Find the role from the cache + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().getRealmRole(realm, roleName); + assertThat(role, notNullValue()); + return null; + }); + + } + + @Test + public void getClientRoleByNameFromTheDatabaseAndTheCache() { + String roleName = "role-" + new Random().nextInt(); + + // Look up a non-existent role from the database + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().getClientByClientId(realm, CLIENT_NAME); + RoleModel role = session.roles().getClientRole(client, roleName); + assertThat(role, nullValue()); + return null; + }); + + // Look up a non-existent role from the cache + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().getClientByClientId(realm, CLIENT_NAME); + RoleModel role = session.roles().getClientRole(client, roleName); + assertThat(role, nullValue()); + return null; + }); + + // Create the role, and invalidate the cache + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().getClientByClientId(realm, CLIENT_NAME); + RoleModel role = session.roles().addClientRole(client, roleName); + assertThat(role, notNullValue()); + return null; + }); + + // Find the role from the database + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().getClientByClientId(realm, CLIENT_NAME); + RoleModel role = session.roles().getClientRole(client, roleName); + assertThat(role, notNullValue()); + return null; + }); + + // Find the role from the cache + withRealm(realmId, (session, realm) -> { + ClientModel client = session.clients().getClientByClientId(realm, CLIENT_NAME); + RoleModel role = session.roles().getClientRole(client, roleName); + assertThat(role, notNullValue()); + return null; + }); + + } + + public void testRolesWithIdsPaginationSearchQueries(GetResult resultProvider) { + // test all parameters together + List result = resultProvider.getResult("1", 4, 3); + assertThat(result, hasSize(3)); + assertIndexValues(result, contains(13, 14, 15)); + } + + private void assertIndexValues(List roles, Matcher> matcher) { + assertThat(roles.stream().map(RoleModel::getName).map(s -> s.substring(ROLE_PREFIX.length())).map(Integer::parseInt).collect(Collectors.toList()), matcher); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/AuthenticationSessionTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/AuthenticationSessionTest.java new file mode 100644 index 00000000..ac2bb1ce --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/AuthenticationSessionTest.java @@ -0,0 +1,236 @@ +package tech.ydb.keycloak.testsuite.session; + +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.AuthenticationSessionProvider; +import org.keycloak.sessions.RootAuthenticationSessionModel; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static tech.ydb.keycloak.testsuite.session.UserSessionPersisterProviderTest.createClients; + +@RequireProvider(value = AuthenticationSessionProvider.class) +public class AuthenticationSessionTest extends KeycloakModelTest { + + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "test"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realm.setAccessCodeLifespanLogin(1800); + + this.realmId = realm.getId(); + + createClients(s, realm); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testLimitAuthSessions() { + AtomicReference rootAuthSessionId = new AtomicReference<>(); + List tabIds = withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel ras = session.authenticationSessions().createRootAuthenticationSession(realm); + rootAuthSessionId.set(ras.getId()); + ClientModel client = realm.getClientByClientId("test-app"); + return IntStream.range(0, 300) + .mapToObj(i -> { + setTimeOffset(i); + return ras.createAuthenticationSession(client); + }) + .map(AuthenticationSessionModel::getTabId) + .collect(Collectors.toList()); + }); + + String tabId = withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel ras = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + ClientModel client = realm.getClientByClientId("test-app"); + + // create 301st auth session + return ras.createAuthenticationSession(client).getTabId(); + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel ras = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + ClientModel client = realm.getClientByClientId("test-app"); + + assertThat(ras.getAuthenticationSessions(), Matchers.aMapWithSize(300)); + + Assert.assertEquals(tabId, ras.getAuthenticationSession(client, tabId).getTabId()); + + // assert the first authentication session was deleted + Assert.assertNull(ras.getAuthenticationSession(client, tabIds.get(0))); + + return null; + }); + } + + @Test + public void testAuthSessions() { + AtomicReference rootAuthSessionId = new AtomicReference<>(); + List tabIds = withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm); + rootAuthSessionId.set(rootAuthSession.getId()); + + ClientModel client = realm.getClientByClientId("test-app"); + return IntStream.range(0, 5) + .mapToObj(i -> { + AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client); + authSession.setExecutionStatus("username", AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); + authSession.setAuthNote("foo", "bar"); + authSession.setClientNote("foo", "bar"); + return authSession; + }) + .map(AuthenticationSessionModel::getTabId) + .collect(Collectors.toList()); + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + Assert.assertNotNull(rootAuthSession); + Assert.assertEquals(rootAuthSessionId.get(), rootAuthSession.getId()); + + ClientModel client = realm.getClientByClientId("test-app"); + tabIds.forEach(tabId -> { + AuthenticationSessionModel authSession = rootAuthSession.getAuthenticationSession(client, tabId); + Assert.assertNotNull(authSession); + + Assert.assertEquals(AuthenticationSessionModel.ExecutionStatus.ATTEMPTED, authSession.getExecutionStatus().get("username")); + Assert.assertEquals("bar", authSession.getAuthNote("foo")); + Assert.assertEquals("bar", authSession.getClientNote("foo")); + }); + + // remove first two auth sessions + rootAuthSession.removeAuthenticationSessionByTabId(tabIds.get(0)); + rootAuthSession.removeAuthenticationSessionByTabId(tabIds.get(1)); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + Assert.assertNotNull(rootAuthSession); + Assert.assertEquals(rootAuthSessionId.get(), rootAuthSession.getId()); + + assertThat(rootAuthSession.getAuthenticationSessions(), Matchers.aMapWithSize(3)); + + Assert.assertNull(rootAuthSession.getAuthenticationSessions().get(tabIds.get(0))); + Assert.assertNull(rootAuthSession.getAuthenticationSessions().get(tabIds.get(1))); + IntStream.range(2,4).mapToObj(i -> rootAuthSession.getAuthenticationSessions().get(tabIds.get(i))).forEach(Assert::assertNotNull); + + session.authenticationSessions().removeRootAuthenticationSession(realm, rootAuthSession); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + Assert.assertNull(rootAuthSession); + + return null; + }); + } + + @Test + public void testRemoveExpiredAuthSessions() { + AtomicReference rootAuthSessionId = new AtomicReference<>(); + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm); + ClientModel client = realm.getClientByClientId("test-app"); + rootAuthSession.createAuthenticationSession(client); + rootAuthSessionId.set(rootAuthSession.getId()); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + Assert.assertNotNull(rootAuthSession); + + setTimeOffset(1900); + + return null; + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootAuthSessionId.get()); + Assert.assertNull(rootAuthSession); + + return null; + }); + } + + @Test + public void testConcurrentAuthenticationSessionsCreation() throws InterruptedException { + final String rootId = withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm); + return rootAuthSession.getId(); + }); + ConcurrentHashMap.KeySetView tabIds = ConcurrentHashMap.newKeySet(); + inIndependentFactories(4, 60, () -> { + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootId); + ClientModel client = realm.getClientByClientId("test-app"); + AuthenticationSessionModel authenticationSession = rootAuthSession.createAuthenticationSession(client); + tabIds.add(authenticationSession.getTabId()); + return null; + }); + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootId); + Assert.assertEquals(4, rootAuthSession.getAuthenticationSessions().size()); + assertThat(rootAuthSession.getAuthenticationSessions().keySet(), Matchers.containsInAnyOrder(tabIds.toArray())); + return null; + }); + } + + @Test + public void testConcurrentAuthenticationSessionsRemoval() throws InterruptedException { + ConcurrentLinkedQueue tabIds = new ConcurrentLinkedQueue<>(); + int concurrentTabs = 4; + final String rootId = withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm); + ClientModel client = realm.getClientByClientId("test-app"); + + for (int i = 0; i < concurrentTabs; i++) { + tabIds.add(rootAuthSession.createAuthenticationSession(client).getTabId()); + } + return rootAuthSession.getId(); + }); + inIndependentFactories(concurrentTabs, 60, () -> { + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootId); + rootAuthSession.removeAuthenticationSessionByTabId(tabIds.remove()); + return null; + }); + }); + + withRealm(realmId, (session, realm) -> { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realm, rootId); + Assert.assertNull(rootAuthSession); + return null; + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/OfflineSessionPersistenceTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/OfflineSessionPersistenceTest.java new file mode 100644 index 00000000..fc78ba24 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/OfflineSessionPersistenceTest.java @@ -0,0 +1,397 @@ +package tech.ydb.keycloak.testsuite.session; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Random; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.hamcrest.Matchers; +import org.infinispan.commons.CacheException; +import org.junit.Ignore; +import org.junit.Test; +import org.keycloak.infinispan.util.InfinispanUtils; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RealmProvider; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProvider; +import org.keycloak.models.sessions.infinispan.PersistentUserSessionProvider; +import org.keycloak.services.managers.RealmManager; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assume.assumeTrue; + +@RequireProvider(UserProvider.class) +@RequireProvider(RealmProvider.class) +@RequireProvider(UserSessionProvider.class) +public class OfflineSessionPersistenceTest extends KeycloakModelTest { + + private static final int USER_COUNT = 50; + private static final int OFFLINE_SESSION_COUNT_PER_USER = 10; + + private String realmId; + private List userIds; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = prepareRealm(s, "realm"); + s.getContext().setRealm(realm); + this.realmId = realm.getId(); + + userIds = IntStream.range(0, USER_COUNT) + .mapToObj(i -> s.users().addUser(realm, "user-" + i)) + .map(UserModel::getId) + .collect(Collectors.toList()); + } + + private static RealmModel prepareRealm(KeycloakSession s, String name) { + RealmModel realm = createRealm(s, name); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realm.setSsoSessionMaxLifespan(10 * 60 * 60); + realm.setSsoSessionIdleTimeout(1 * 60 * 60); + realm.setOfflineSessionMaxLifespan(365 * 24 * 60 * 60); + realm.setOfflineSessionIdleTimeout(30 * 24 * 60 * 60); + return realm; + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + new RealmManager(s).removeRealm(realm); // See https://issues.redhat.com/browse/KEYCLOAK-17876 + } + + @Test + public void testPersistenceSingleNodeDeleteRealm() { + String realmId2 = inComittedTransaction(session -> { return prepareRealm(session, "realm2").getId(); }); + List userIds2 = withRealm(realmId2, (session, realm) -> IntStream.range(0, USER_COUNT) + .mapToObj(i -> session.users().addUser(realm, "user2-" + i)) + .map(UserModel::getId) + .collect(Collectors.toList()) + ); + + try { + List offlineSessionIds = createOfflineSessions(realmId, userIds); + assertOfflineSessionsExist(realmId, offlineSessionIds); + + List offlineSessionIds2 = createOfflineSessions(realmId2, userIds2); + assertOfflineSessionsExist(realmId2, offlineSessionIds2); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + + withRealm(realmId2, (session, realm) -> new RealmManager(session).removeRealm(realm)); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + assertOfflineSessionsExist(realmId, offlineSessionIds); + } finally { + withRealm(realmId2, (session, realm) -> realm == null ? false : new RealmManager(session).removeRealm(realm)); + } + } + + @Test + public void testPersistenceSingleNode() { + List offlineSessionIds = createOfflineSessions(realmId, userIds); + assertOfflineSessionsExist(realmId, offlineSessionIds); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + assertOfflineSessionsExist(realmId, offlineSessionIds); + } + + @Test(timeout = 90 * 1000) + @RequireProvider(UserSessionPersisterProvider.class) + public void testPersistenceMultipleNodesClientSessionAtSameNode() throws InterruptedException { + int numClients = 2; + List clientIds = withRealm(realmId, (session, realm) -> IntStream.range(0, numClients) + .mapToObj(cid -> session.clients().addClient(realm, "client-" + cid)) + .map(ClientModel::getId) + .collect(Collectors.toList())); + + // Shutdown factory -> enforce session persistence + closeKeycloakSessionFactory(); + Set clientSessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + int NUM_FACTORIES = 3; + CountDownLatch intermediate = new CountDownLatch(NUM_FACTORIES); + inIndependentFactories(NUM_FACTORIES, 60, () -> { + withRealm(realmId, (session, realm) -> { + // Create offline sessions + userIds.stream().limit(userIds.size() / 10).forEach(userId -> createOfflineSessions(session, realm, userId, offlineUserSession -> { + IntStream.range(0, numClients) + .mapToObj(cid -> session.clients().getClientById(realm, clientIds.get(cid))) + // TODO in the future: The following two lines are weird. Why an online client session needs to exist in order to create an offline one? + .map(client -> session.sessions().createClientSession(realm, client, offlineUserSession)) + .map(clientSession -> session.sessions().createOfflineClientSession(clientSession, offlineUserSession)) + .map(AuthenticatedClientSessionModel::getId) + .forEach(s -> {}); // ensure that stream is consumed + }).forEach(userSessionModel -> clientSessionIds.add(userSessionModel.getId()))); + return null; + }); + + // ensure that all session have been created on all nodes + intermediate.countDown(); + try { + intermediate.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + // defer the shutdown and check if all sessions exist to ensure that they replicate across the different nodes + // this should avoid an "org.infinispan.remoting.transport.jgroups.SuspectException: ISPN000400: Node node-XX was suspected" + while (true) { + try { + assertOfflineSessionsExist(realmId, clientSessionIds); + break; + } catch (AssertionError e) { + log.warn("assertion failed, retrying to see if all sessions exist."); + sleep(1000); + } + } + }); + + reinitializeKeycloakSessionFactory(); + inIndependentFactories(NUM_FACTORIES + 1, 30, () -> assertOfflineSessionsExist(realmId, clientSessionIds)); + } + + @Test(timeout = 90 * 1000) + @RequireProvider(UserSessionPersisterProvider.class) + public void testPersistenceMultipleNodesClientSessionsAtRandomNode() throws InterruptedException { + List clientIds = withRealm(realmId, (session, realm) -> IntStream.range(0, 5) + .mapToObj(cid -> session.clients().addClient(realm, "client-" + cid)) + .map(ClientModel::getId) + .collect(Collectors.toList())); + List offlineSessionIds = createOfflineSessions(realmId, userIds); + + // Shutdown factory -> enforce session persistence + closeKeycloakSessionFactory(); + + Map> clientSessionIds = new ConcurrentHashMap<>(); + AtomicInteger i = new AtomicInteger(); + inIndependentFactories(3, 60, () -> { + for (int j = 0; j < USER_COUNT * 3; j ++) { + int index = i.incrementAndGet(); + int oid = index % offlineSessionIds.size(); + String offlineSessionId = offlineSessionIds.get(oid); + int cid = index % clientIds.size(); + try { + clientSessionIds.computeIfAbsent(offlineSessionId, a -> Collections.synchronizedList(new LinkedList<>())).add(createOfflineClientSession(offlineSessionId, clientIds.get(cid))); + } catch (RuntimeException ex) { + // invocation can fail when remote cache is stopping, this is actually part of this test: + // "ISPN000217: Received exception from node-8, see cause for remote stack trace + // IllegalLifecycleStateException: ISPN000324: Cache 'clientSessions' is in 'STOPPING' state and this is an invocation not belonging to an + // on-going transaction, so it does not accept new invocations." + // also: org.infinispan.commons.CacheException: java.lang.IllegalStateException: Read commands must ignore leavers + if ((ex.getCause() != null && ex.getCause().getMessage().contains("ISPN000324")) || + (ex.getMessage() != null && ex.getMessage().contains("ISPN000217")) || + (ex instanceof CacheException && ex.getMessage().contains("Read commands must ignore leavers"))) { + log.warn("invocation failed, skipping. Retrying might lead to a 'Unique index or primary key violation' when the offline session has already been stored in the DB in the current session", ex); + } else { + throw ex; + } + } + + // re-initialize the session factory N times in this test + if (index % 100 == 0) { + reinitializeKeycloakSessionFactory(); + } + } + }); + + reinitializeKeycloakSessionFactory(); + assertOfflineSessionsExist(realmId, offlineSessionIds); + } + + @Test + @RequireProvider(UserSessionPersisterProvider.class) + public void testOfflineSessionLoadingAfterCacheRemoval() { + assumeTrue("Run only if Embedded Infinispan is used for storing/caching sessions.", InfinispanUtils.isEmbeddedInfinispan()); + + List offlineSessionIds = createOfflineSessions(realmId, userIds); + assertOfflineSessionsExist(realmId, offlineSessionIds); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + assertOfflineSessionsExist(realmId, offlineSessionIds); + + // remove sessions from the cache + withRealm(realmId, (session, realm) -> { + // Delete local user cache (persisted sessions are still kept) + UserSessionProvider provider = session.getProvider(UserSessionProvider.class); + // Remove in-memory representation of the offline sessions + if (provider instanceof InfinispanUserSessionProvider) { + ((InfinispanUserSessionProvider) provider).removeLocalUserSessions(realm.getId(), true); + } else if (provider instanceof PersistentUserSessionProvider) { + ((PersistentUserSessionProvider) provider).removeLocalUserSessions(realm.getId(), true); + } else { + throw new IllegalStateException("Unknown UserSessionProvider: " + provider); + } + + return null; + }); + + // assert sessions are lazily loaded from DB + assertOfflineSessionsExist(realmId, offlineSessionIds); + } + + @Test + @RequireProvider(UserSessionPersisterProvider.class) + public void testLazyClientSessionStatsFetching() { + List clientIds = withRealm(realmId, (session, realm) -> IntStream.range(0, 5) + .mapToObj(cid -> session.clients().addClient(realm, "client-" + cid)) + .map(ClientModel::getId) + .collect(Collectors.toList())); + + List offlineSessionIds = createOfflineSessions(realmId, userIds); + assertOfflineSessionsExist(realmId, offlineSessionIds); + + Random r = new Random(); + offlineSessionIds.stream().forEach(offlineSessionId -> createOfflineClientSession(offlineSessionId, clientIds.get(r.nextInt(5)))); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + + // load active client sessions stats from DB + Map sessionStats = withRealm(realmId, (session, realm) -> session.sessions().getActiveClientSessionStats(realm, true)); + + long client1SessionCount = sessionStats.get(clientIds.get(0)); + int clientSessionsCount = sessionStats.values().stream().reduce(0l, Long::sum).intValue(); + assertThat(clientSessionsCount, Matchers.is(USER_COUNT * OFFLINE_SESSION_COUNT_PER_USER)); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + + long actualClient1SessionCount = withRealm(realmId, (session, realm) -> { + ClientModel client = realm.getClientById(clientIds.get(0)); + return session.sessions().getOfflineSessionsCount(realm, client); + }); + assertThat(actualClient1SessionCount, Matchers.is(client1SessionCount)); + } + + @Test + @RequireProvider(UserSessionPersisterProvider.class) + @Ignore("Ydb fails with memory limit on sql request https://github.com/ydb-platform/ydb/issues/40829") + public void testLazyOfflineUserSessionFetching() { + Map> offlineSessionIdsDetailed = createOfflineSessionsDetailed(realmId, userIds); + Collection offlineSessionIds = offlineSessionIdsDetailed.values().stream().flatMap(Set::stream).collect(Collectors.toCollection(TreeSet::new)); + assertOfflineSessionsExist(realmId, offlineSessionIds); + + // Simulate server restart + reinitializeKeycloakSessionFactory(); + + Map> actualOfflineSessionIds = withRealm(realmId, (session, realm) -> session.users() + .searchForUserStream(realm, Collections.emptyMap()) + .collect(Collectors.toMap( + UserModel::getId, + user -> session.sessions().getOfflineUserSessionsStream(realm, user).map(UserSessionModel::getId).collect(Collectors.toCollection(TreeSet::new)) + )) + ); + + assertThat("User IDs", actualOfflineSessionIds.keySet(), equalTo(offlineSessionIdsDetailed.keySet())); + for (Entry> me : offlineSessionIdsDetailed.entrySet()) { + assertThat("Session IDs", actualOfflineSessionIds.get(me.getKey()), equalTo(me.getValue())); + } + } + + private String createOfflineClientSession(String offlineUserSessionId, String clientId) { + return withRealm(realmId, (session, realm) -> { + UserSessionModel offlineUserSession = session.sessions().getOfflineUserSession(realm, offlineUserSessionId); + assertThat("Can't retrieve offline session for " + offlineUserSessionId, offlineUserSession, Matchers.notNullValue()); + ClientModel client = session.clients().getClientById(realm, clientId); + AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, client, offlineUserSession); + return session.sessions().createOfflineClientSession(clientSession, offlineUserSession).getId(); + }); + } + + @Test(timeout = 90 * 1000) + @RequireProvider(UserSessionPersisterProvider.class) + public void testPersistenceClientSessionsMultipleNodes() throws InterruptedException { + // Create offline sessions + List offlineSessionIds = createOfflineSessions(realmId, userIds); + + // Shutdown factory -> enforce session persistence + closeKeycloakSessionFactory(); + + inIndependentFactories(4, 60, () -> assertOfflineSessionsExist(realmId, offlineSessionIds)); + } + + /** + * Assert that all the offline sessions passed in the {@code offlineSessionIds} parameter exist + */ + private void assertOfflineSessionsExist(String realmId, Collection offlineSessionIds) { + int foundOfflineSessions = withRealm(realmId, (session, realm) -> offlineSessionIds.stream() + .map(offlineSessionId -> session.sessions().getOfflineUserSession(realm, offlineSessionId)) + .map(ous -> ous == null ? 0 : 1) + .reduce(0, Integer::sum)); + + assertThat(foundOfflineSessions, Matchers.is(offlineSessionIds.size())); + // catch a programming error where an empty collection of offline session IDs is passed + assertThat(foundOfflineSessions, Matchers.greaterThan(0)); + } + + // ***************** Helper methods ***************** + + /** + * Creates {@link #OFFLINE_SESSION_COUNT_PER_USER} offline sessions for every user from {@link #userIds}. + * @return Ids of the offline sessions + */ + private List createOfflineSessions(String realmId, List userIds) { + return withRealm(realmId, (session, realm) -> + userIds.stream() + .flatMap(userId -> createOfflineSessions(session, realm, userId, us -> {})) + .map(UserSessionModel::getId) + .collect(Collectors.toList()) + ); + } + + private Map> createOfflineSessionsDetailed(String realmId, List userIds) { + return withRealm(realmId, (session, realm) -> + userIds.stream() + .collect(Collectors.toMap( + Function.identity(), + userId -> createOfflineSessions(session, realm, userId, us -> {}).map(UserSessionModel::getId).collect(Collectors.toCollection(TreeSet::new)) + )) + ); + } + + /** + * Creates {@link #OFFLINE_SESSION_COUNT_PER_USER} offline sessions for {@code userId} user. + */ + private Stream createOfflineSessions(KeycloakSession session, RealmModel realm, String userId, Consumer alterUserSession) { + return IntStream.range(0, OFFLINE_SESSION_COUNT_PER_USER) + .mapToObj(sess -> createOfflineSession(session, realm, userId, sess)) + .peek(alterUserSession == null ? us -> {} : us -> alterUserSession.accept(us)); + } + + private UserSessionModel createOfflineSession(KeycloakSession session, RealmModel realm, String userId, int sessionIndex) { + final UserModel user = session.users().getUserById(realm, userId); + UserSessionModel us = session.sessions().createUserSession(null, realm, user, "un" + sessionIndex, "ip1", "auth", false, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + return session.sessions().createOfflineUserSession(us); + } + +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionPersisterProviderTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionPersisterProviderTest.java new file mode 100644 index 00000000..7434d77a --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionPersisterProviderTest.java @@ -0,0 +1,874 @@ +package tech.ydb.keycloak.testsuite.session; + +import org.hamcrest.Matchers; +import org.infinispan.Cache; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.common.Profile; +import org.keycloak.common.util.MultiSiteUtils; +import org.keycloak.common.util.Time; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.infinispan.util.InfinispanUtils; +import org.keycloak.models.*; +import org.keycloak.models.jpa.session.JpaUserSessionPersisterProvider; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; +import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey; +import org.keycloak.models.utils.ResetTimeOffsetEvent; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.managers.ClientManager; +import org.keycloak.services.managers.RealmManager; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.*; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.*; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; + +@RequireProvider(UserSessionPersisterProvider.class) +@RequireProvider(UserSessionProvider.class) +@RequireProvider(UserProvider.class) +@RequireProvider(RealmProvider.class) +public class UserSessionPersisterProviderTest extends KeycloakModelTest { + + private static final int USER_SESSION_COUNT = 2000; + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "test"); + s.getContext().setRealm(realm); + realm.setSsoSessionMaxLifespan(Constants.DEFAULT_SESSION_MAX_LIFESPAN); + realm.setSsoSessionIdleTimeout(Constants.DEFAULT_SESSION_IDLE_TIMEOUT); + realm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT); + realm.setOfflineSessionMaxLifespan(Constants.DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + + s.users().addUser(realm, "user1").setEmail("user1@localhost"); + s.users().addUser(realm, "user2").setEmail("user2@localhost"); + + createClients(s, realm); + } + + protected static void createClients(KeycloakSession s, RealmModel realm) { + ClientModel clientModel = s.clients().addClient(realm, "test-app"); + clientModel.setEnabled(true); + clientModel.setBaseUrl("http://localhost:8180/auth/realms/master/app/auth"); + Set redirects = new HashSet<>(Arrays.asList("http://localhost:8180/auth/realms/master/app/auth/*", + "https://localhost:8543/auth/realms/master/app/auth/*", + "http://localhost:8180/auth/realms/test/app/auth/*", + "https://localhost:8543/auth/realms/test/app/auth/*")); + clientModel.setRedirectUris(redirects); + clientModel.setSecret("password"); + + clientModel = s.clients().addClient(realm, "third-party"); + clientModel.setEnabled(true); + clientModel.setConsentRequired(true); + clientModel.setBaseUrl("http://localhost:8180/auth/realms/master/app/auth"); + clientModel.setRedirectUris(redirects); + clientModel.setSecret("password"); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.sessions().removeUserSessions(realm); + + UserModel user1 = s.users().getUserByUsername(realm, "user1"); + UserModel user2 = s.users().getUserByUsername(realm, "user2"); + + UserManager um = new UserManager(s); + if (user1 != null) { + um.removeUser(realm, user1); + } + if (user2 != null) { + um.removeUser(realm, user2); + } + + s.realms().removeRealm(realmId); + } + + @Test + public void testPersistenceWithLoad() { + int started = Time.currentTime(); + final UserSessionModel[] userSession = new UserSessionModel[1]; + + UserSessionModel[] origSessions = inComittedTransaction(session -> { + // Create some sessions in infinispan + return createSessions(session, realmId); + }); + + inComittedTransaction(session -> { + // Persist 3 created userSessions and clientSessions as offline + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + ClientModel testApp = realm.getClientByClientId("test-app"); + session.sessions().getUserSessionsStream(realm, testApp).toList() + .forEach(userSessionLooper -> persistUserSession(session, userSessionLooper, true)); + }); + + if (!MultiSiteUtils.isPersistentSessionsEnabled()) { + inComittedTransaction(session -> { + // Persist 1 online session + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + userSession[0] = session.sessions().getUserSession(realm, origSessions[0].getId()); + persistUserSession(session, userSession[0], false); + }); + + inComittedTransaction(session -> { // Assert online session + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + List loadedSessions = loadPersistedSessionsPaginated(session, false, 1, 1, 1); + assertSession(loadedSessions.get(0), session.users().getUserByUsername(realm, "user1"), "127.0.0.1", started, started, "test-app", "third-party"); + }); + } + + inComittedTransaction(session -> { + // Assert offline sessions + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + List loadedSessions = loadPersistedSessionsPaginated(session, true, 2, 2, 3); + assertSessions(loadedSessions, new String[] { origSessions[0].getId(), origSessions[1].getId(), origSessions[2].getId() }); + + assertSessionLoaded(loadedSessions, origSessions[0].getId(), session.users().getUserByUsername(realm, "user1"), "127.0.0.1", started, started, "test-app", "third-party"); + assertSessionLoaded(loadedSessions, origSessions[1].getId(), session.users().getUserByUsername(realm, "user1"), "127.0.0.2", started, started, "test-app"); + assertSessionLoaded(loadedSessions, origSessions[2].getId(), session.users().getUserByUsername(realm, "user2"), "127.0.0.3", started, started, "test-app"); + }); + } + + @Test + public void testUpdateAndRemove() { + int started = Time.currentTime(); + + AtomicReference origSessionsAt = new AtomicReference<>(); + AtomicReference userSessionAt = new AtomicReference<>(); + + inComittedTransaction(session -> { + // Create some sessions in infinispan + UserSessionModel[] origSessions = createSessions(session, realmId); + origSessionsAt.set(origSessions); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + UserSessionModel[] origSessions = origSessionsAt.get(); + + // Persist 1 offline session + UserSessionModel userSession = session.sessions().getUserSession(realm, origSessions[1].getId()); + userSessionAt.set(userSession); + + persistUserSession(session, userSession, true); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + + // Load offline session + List loadedSessions = loadPersistedSessionsPaginated(session, true, 10, 1, 1); + + UserSessionModel persistedSession = loadedSessions.get(0); + + assertSession(persistedSession, session.users().getUserByUsername(realm, "user1"), "127.0.0.2", started, started, "test-app"); + + // create new clientSession + AuthenticatedClientSessionModel clientSession = createClientSession(session, realmId, realm.getClientByClientId("third-party"), session.sessions().getUserSession(realm, persistedSession.getId()), + "http://redirect", "state"); + persister.createClientSession(clientSession, true); + + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + UserSessionModel userSession = userSessionAt.get(); + + // Remove clientSession + persister.removeClientSession(userSession.getId(), realm.getClientByClientId("third-party").getId(), true); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + + // Assert clientSession removed + List loadedSessions = loadPersistedSessionsPaginated(session, true, 10, 1, 1); + UserSessionModel persistedSession = loadedSessions.get(0); + assertSession(persistedSession, session.users().getUserByUsername(realm, "user1"), "127.0.0.2", started, started, "test-app"); + + // Remove userSession + persister.removeUserSession(persistedSession.getId(), true); + }); + + inComittedTransaction(session -> { + // Assert nothing found + loadPersistedSessionsPaginated(session, true, 10, 0, 0); + }); + } + + @Test + public void testOnRealmRemoved() { + AtomicReference userSessionID = new AtomicReference<>(); + + inComittedTransaction(session -> { + RealmModel fooRealm = session.realms().createRealm("foo"); + session.getContext().setRealm(fooRealm); + fooRealm.setDefaultRole(session.roles().addRealmRole(fooRealm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + fooRealm.getName())); + + fooRealm.addClient("foo-app"); + session.users().addUser(fooRealm, "user3"); + + UserSessionModel userSession = session.sessions().createUserSession(null, fooRealm, session.users().getUserByUsername(fooRealm, "user3"), "user3", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSessionID.set(userSession.getId()); + + createClientSession(session, fooRealm.getId(), fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state"); + }); + + inComittedTransaction(session -> { + // Persist offline session + RealmModel fooRealm = session.realms().getRealmByName("foo"); + session.getContext().setRealm(fooRealm); + UserSessionModel userSession = session.sessions().getUserSession(fooRealm, userSessionID.get()); + assertNotNull(userSession); + persistUserSession(session, userSession, true); + }); + + inComittedTransaction(session -> { + // Assert session was persisted + loadPersistedSessionsPaginated(session, true, 10, 1, 1); + + // Remove realm + RealmManager realmMgr = new RealmManager(session); + RealmModel fooRealm = realmMgr.getRealmByName("foo"); + session.getContext().setRealm(fooRealm); + realmMgr.removeRealm(fooRealm); + }); + + inComittedTransaction(session -> { + // Assert nothing loaded + loadPersistedSessionsPaginated(session, true, 10, 0, 0); + }); + } + + @Test + public void testOnClientRemoved() { + int started = Time.currentTime(); + AtomicReference userSessionID = new AtomicReference<>(); + + inComittedTransaction(session -> { + RealmModel fooRealm = session.realms().createRealm("foo"); + session.getContext().setRealm(fooRealm); + fooRealm.setDefaultRole(session.roles().addRealmRole(fooRealm, Constants.DEFAULT_ROLES_ROLE_PREFIX)); + + fooRealm.addClient("foo-app"); + fooRealm.addClient("bar-app"); + session.users().addUser(fooRealm, "user3"); + + UserSessionModel userSession = session.sessions().createUserSession(null, fooRealm, session.users().getUserByUsername(fooRealm, "user3"), "user3", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSessionID.set(userSession.getId()); + + createClientSession(session, fooRealm.getId(), fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state"); + createClientSession(session, fooRealm.getId(), fooRealm.getClientByClientId("bar-app"), userSession, "http://redirect", "state"); + }); + + inComittedTransaction(session -> { + RealmModel fooRealm = session.realms().getRealmByName("foo"); + session.getContext().setRealm(fooRealm); + + // Persist offline session + UserSessionModel userSession = session.sessions().getUserSession(fooRealm, userSessionID.get()); + persistUserSession(session, userSession, true); + }); + + inComittedTransaction(session -> { + RealmManager realmMgr = new RealmManager(session); + ClientManager clientMgr = new ClientManager(realmMgr); + RealmModel fooRealm = realmMgr.getRealmByName("foo"); + session.getContext().setRealm(fooRealm); + + // Assert session was persisted with both clientSessions + UserSessionModel persistedSession = loadPersistedSessionsPaginated(session, true, 10, 1, 1).get(0); + assertSession(persistedSession, session.users().getUserByUsername(fooRealm, "user3"), "127.0.0.1", started, started, "foo-app", "bar-app"); + + // Remove foo-app client + ClientModel client = fooRealm.getClientByClientId("foo-app"); + clientMgr.removeClient(fooRealm, client); + }); + + inComittedTransaction(session -> { + RealmManager realmMgr = new RealmManager(session); + ClientManager clientMgr = new ClientManager(realmMgr); + RealmModel fooRealm = realmMgr.getRealmByName("foo"); + session.getContext().setRealm(fooRealm); + + // Assert just one bar-app clientSession persisted now + UserSessionModel persistedSession = loadPersistedSessionsPaginated(session, true, 10, 1, 1).get(0); + assertSession(persistedSession, session.users().getUserByUsername(fooRealm, "user3"), "127.0.0.1", started, started, "bar-app"); + + // Remove bar-app client + ClientModel client = fooRealm.getClientByClientId("bar-app"); + clientMgr.removeClient(fooRealm, client); + }); + + inComittedTransaction(session -> { + // Assert loading still works - last userSession is still there, but no clientSession on it + loadPersistedSessionsPaginated(session, true, 10, 1, 1); + + // Cleanup + RealmManager realmMgr = new RealmManager(session); + RealmModel fooRealm = realmMgr.getRealmByName("foo"); + session.getContext().setRealm(fooRealm); + realmMgr.removeRealm(fooRealm); + }); + } + + @Test + public void testClientTimestampUpdate() { + final String realmName = "client-test"; + final String username = "my-user"; + final String clientId = "my-app"; + final AtomicReference userSessionID = new AtomicReference<>(); + + // create user and client + inComittedTransaction(session -> { + RealmModel realm = session.realms().createRealm(realmName); + session.getContext().setRealm(realm); + realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX)); + + realm.addClient(clientId); + session.users().addUser(realm, username); + + UserSessionModel userSession = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, username), username, "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSessionID.set(userSession.getId()); + + createClientSession(session, realm.getId(), realm.getClientByClientId(clientId), userSession, "http://redirect", "state"); + }); + + if (InfinispanUtils.isEmbeddedInfinispan()) { + // causes https://github.com/keycloak/keycloak/issues/42012 + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealmByName(realmName); + session.getContext().setRealm(realm); + + var cacheKey = new EmbeddedClientSessionKey(userSessionID.get(), realm.getClientByClientId(clientId).getId()); + Cache> clientSessoinCache = session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME); + SessionEntityWrapper clientSession = clientSessoinCache.get(cacheKey); + assertNotNull(clientSession); + assertNotNull(clientSession.getEntity()); + // user session id is not stored in the cache + // when reading from a remote keycloak instance, this field is null + // we are simulating a “remote read” here. + clientSession.getEntity().setUserSessionId(null); + }); + } + + + Function fetchTimestamp = session -> { + RealmModel realm = session.realms().getRealmByName(realmName); + session.getContext().setRealm(realm); + + ClientModel client = realm.getClientByClientId(clientId); + UserSessionModel userSession = session.sessions().getUserSession(realm, userSessionID.get()); + // read from database! + if (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS)) { + return session.getProvider(UserSessionPersisterProvider.class) + .loadClientSession(realm, client, userSession, false) + .getTimestamp(); + } + return session.sessions() + .getClientSession(userSession, client, false) + .getTimestamp(); + }; + + // fetch the current timestamp + int currentTimestamp = inComittedTransaction(fetchTimestamp); + + // update timestamp + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealmByName(realmName); + session.getContext().setRealm(realm); + + ClientModel client = realm.getClientByClientId(clientId); + UserSessionModel userSession = session.sessions().getUserSession(realm, userSessionID.get()); + session.sessions() + .getClientSession(userSession, client, false) + .setTimestamp(currentTimestamp + 10); + }); + + // check if it is updated + int timestamp = inComittedTransaction(fetchTimestamp); + assertEquals(currentTimestamp + 10, timestamp); + } + + @Test + public void testOnUserRemoved() { + int started = Time.currentTime(); + AtomicReference origSessionsAt = new AtomicReference<>(); + + inComittedTransaction(session -> { + // Create some sessions in infinispan + UserSessionModel[] origSessions = createSessions(session, realmId); + origSessionsAt.set(origSessions); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + UserSessionModel[] origSessions = origSessionsAt.get(); + + // Persist 2 offline sessions of 2 users + UserSessionModel userSession1 = session.sessions().getUserSession(realm, origSessions[1].getId()); + UserSessionModel userSession2 = session.sessions().getUserSession(realm, origSessions[2].getId()); + persistUserSession(session, userSession1, true); + persistUserSession(session, userSession2, true); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + // Load offline sessions + loadPersistedSessionsPaginated(session, true, 10, 1, 2); + + // Properly delete user and assert his offlineSession removed + UserModel user1 = session.users().getUserByUsername(realm, "user1"); + new UserManager(session).removeUser(realm, user1); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + if (InfinispanUtils.isEmbeddedInfinispan()) { + // when configured with external Infinispan only, the sessions are not persisted into the database. + Assert.assertEquals(1, persister.getUserSessionsCount(true)); + } + + List loadedSessions = loadPersistedSessionsPaginated(session, true, 10, 1, 1); + UserSessionModel persistedSession = loadedSessions.get(0); + assertSession(persistedSession, session.users().getUserByUsername(realm, "user2"), "127.0.0.3", started, started, "test-app"); + + // KEYCLOAK-2431 Assert that userSessionPersister is resistent even to situation, when users are deleted "directly". + // No exception will happen. However session will be still there + UserModel user2 = session.users().getUserByUsername(realm, "user2"); + session.users().removeUser(realm, user2); + + loadedSessions = loadPersistedSessionsPaginated(session, true, 10, 1, 1); + + // Cleanup + UserSessionModel userSession = loadedSessions.get(0); + session.sessions().removeUserSession(realm, userSession); + persister.removeUserSession(userSession.getId(), userSession.isOffline()); + }); + } + + // KEYCLOAK-1999 + @Test + public void testNoSessions() { + inComittedTransaction(session -> { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + Stream sessions = persister.loadUserSessionsStream(0, 1, true, "00000000-0000-0000-0000-000000000000"); + Assert.assertEquals(0, sessions.count()); + }); + } + + @Test + public void testMoreSessions() { + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + // Create 10 userSessions - each having 1 clientSession + List userSessionsInner = new LinkedList<>(); + UserModel user = session.users().getUserByUsername(realm, "user1"); + + for (int i = 0; i < USER_SESSION_COUNT; i++) { + // Having different offsets for each session (to ensure that lastSessionRefresh is also different) + setTimeOffset(i); + + UserSessionModel userSession = session.sessions().createUserSession(null, realm, user, "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + if (userSessionsInner.contains(userSession.getId())) { + Assert.fail("Duplicate session id generated: " + userSession.getId()); + } + + createClientSession(session, realmId, realm.getClientByClientId("test-app"), userSession, "http://redirect", "state"); + userSessionsInner.add(userSession.getId()); + } + + for (String userSessionId : userSessionsInner) { + UserSessionModel userSession2 = session.sessions().getUserSession(realm, userSessionId); + persistUserSession(session, userSession2, true); + } + + return null; + }); + + withRealm(realmId, (session, realm) -> { + final int sessionsPerPage = 3; + List loadedSessions = loadPersistedSessionsPaginated(session, true, sessionsPerPage, + USER_SESSION_COUNT / sessionsPerPage + (USER_SESSION_COUNT / sessionsPerPage == 0 ? 0 : 1), USER_SESSION_COUNT); + UserModel user = session.users().getUserByUsername(realm, "user1"); + ClientModel testApp = realm.getClientByClientId("test-app"); + + for (UserSessionModel loadedSession : loadedSessions) { + assertEquals(user.getId(), loadedSession.getUser().getId()); + assertEquals("127.0.0.1", loadedSession.getIpAddress()); + assertEquals(user.getUsername(), loadedSession.getLoginUsername()); + + assertEquals(1, loadedSession.getAuthenticatedClientSessions().size()); + assertTrue(loadedSession.getAuthenticatedClientSessions().containsKey(testApp.getId())); + } + return null; + }); + + } + + @Test + public void testConcurrentSessionCreation() { + String userSessionId = withRealm(realmId, (session, realm) -> { + UserModel user = session.users().getUserByUsername(realm, "user1"); + UserSessionModel userSession = session.sessions().createUserSession(null, realm, user, "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSession.setNote("ITERATION1", "true"); + return userSession.getId(); + }); + + // Simulate a concurrently created session + withRealm(realmId, (session, realm) -> { + UserModel user = session.users().getUserByUsername(realm, "user1"); + UserSessionModel userSession = session.sessions().createUserSession(userSessionId, realm, user, "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSession.setNote("ITERATION2", "true"); + return null; + }); + + withRealm(realmId, (session, realm) -> { + UserSessionModel userSession = session.sessions().getUserSession(realm, userSessionId); + assertThat(userSession.getNote("ITERATION1"), Matchers.equalTo("true")); + assertThat(userSession.getNote("ITERATION2"), Matchers.equalTo("true")); + return null; + }); + + if (MultiSiteUtils.isPersistentSessionsEnabled()) { + try { + // Simulate a concurrently created session with a different user + withRealm(realmId, (session, realm) -> { + UserModel user = session.users().getUserByUsername(realm, "user2"); + UserSessionModel userSession = session.sessions().createUserSession(userSessionId, realm, user, "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSession.setNote("ITERATION2", "true"); + return null; + }); + Assert.fail("Exception expected"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), Matchers.containsString("Maximum number of retries reached")); + assertThat(e.getCause().getMessage(), Matchers.containsString("User ID of the session does not match")); + } + } + + } + + @Test + public void testExpiredSessions() { + int started = Time.currentTime(); + final UserSessionModel[] userSession1 = {null}; + final UserSessionModel[] userSession2 = {null}; + + UserSessionModel[] origSessions = inComittedTransaction(session -> { + // Create some sessions in infinispan + return createSessions(session, realmId); + }); + + inComittedTransaction(session -> { + // Persist 2 offline sessions of 2 users + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + userSession1[0] = session.sessions().getUserSession(realm, origSessions[1].getId()); + userSession2[0] = session.sessions().getUserSession(realm, origSessions[2].getId()); + persistUserSession(session, userSession1[0], true); + persistUserSession(session, userSession2[0], true); + }); + + inComittedTransaction(session -> { + // Update one of the sessions with lastSessionRefresh of 20 days ahead + int lastSessionRefresh = Time.currentTime() + 1728000; + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + + persister.updateLastSessionRefreshes(realm, lastSessionRefresh, Collections.singleton(userSession1[0].getId()), true); + + // Increase time offset - 40 days + setTimeOffset(3456000); + try { + // Run expiration thread + persister.removeExpired(realm); + + // Test the updated session is still in persister. Not updated session is not there anymore + List loadedSessions = loadPersistedSessionsPaginated(session, true, 10, 1, 1); + UserSessionModel persistedSession = loadedSessions.get(0); + assertSession(persistedSession, session.users().getUserByUsername(realm, "user1"), "127.0.0.2", started, lastSessionRefresh, "test-app"); + + } finally { + // Cleanup + setTimeOffset(0); + session.getKeycloakSessionFactory().publish(new ResetTimeOffsetEvent()); + } + }); + } + + @Test + public void testUserRemoved() throws InterruptedException { + final String userName = "to-remove"; + final int numberOfSessions = 5; + final int clusterSize = 4; + inComittedTransaction(session -> { + RealmModel realm = getRealm(session); + session.sessions().removeUserSessions(realm); + session.users().addUser(realm, userName).setEmail(userName + "@localhost"); + }); + + final UserSessionCount initial = getUserSessionCount(); + final CyclicBarrier barrier = new CyclicBarrier(clusterSize); + final AtomicBoolean userDeleted = new AtomicBoolean(false); + + inIndependentFactories(clusterSize, 60, () -> { + try { + barrier.await(10, TimeUnit.SECONDS); + inComittedTransaction(session -> { + RealmModel realm = getRealm(session); + UserModel user = session.users().getUserByUsername(realm, userName); + ClientModel testApp = realm.getClientByClientId("test-app"); + IntStream.range(0, numberOfSessions) + .forEach(ignored -> { + UserSessionModel us = session.sessions().createUserSession(null, realm, user, userName, "127.0.0.1", "form", false, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + session.sessions().createClientSession(realm, testApp, us); + }); + }); + + barrier.await(10, TimeUnit.SECONDS); + assertSessionCount(numberOfSessions * clusterSize, initial); + + barrier.await(10, TimeUnit.SECONDS); + if (userDeleted.compareAndSet(false, true)) { + inComittedTransaction(session -> { + RealmModel realm = getRealm(session); + UserModel user = session.users().getUserByUsername(realm, userName); + new UserManager(session).removeUser(realm, user); + }); + } + + barrier.await(10, TimeUnit.SECONDS); + assertSessionCount(0, initial); + + barrier.await(10, TimeUnit.SECONDS); + } catch (BrokenBarrierException | TimeoutException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + private UserSessionCount getUserSessionCount() { + if (InfinispanUtils.isEmbeddedInfinispan()) { + return MultiSiteUtils.isPersistentSessionsEnabled() ? + new UserSessionCount(getPersistedUserSessionsCount(), getEmbeddedCachedUserSessionsCount()) : + new UserSessionCount(-1, getEmbeddedCachedUserSessionsCount()); + + } + return MultiSiteUtils.isPersistentSessionsEnabled() ? + new UserSessionCount(getPersistedUserSessionsCount(), -1) : + new UserSessionCount(-1, getRemoteCachedUserSessionsCount()); + } + + private void assertSessionCount(int offset, UserSessionCount initial) { + UserSessionCount current = getUserSessionCount(); + if (initial.database() != -1) { + assertEquals("Wrong number of session in database", initial.database() + offset, current.database()); + } else { + assertEquals("Wrong number of session in database", initial.database(), current.database()); + } + if (initial.cache() != -1) { + assertEquals("Wrong number of session in cache", initial.cache() + offset, current.cache()); + } else { + assertEquals("Wrong number of session in cache", initial.cache(), current.cache()); + } + } + + private int getRemoteCachedUserSessionsCount() { + return inComittedTransaction(session -> { + getRealm(session); + return session.getProvider(InfinispanConnectionProvider.class).getRemoteCache(USER_SESSION_CACHE_NAME).size(); + }); + } + + private int getEmbeddedCachedUserSessionsCount() { + return inComittedTransaction(session -> { + getRealm(session); + return session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).size(); + }); + } + + private int getPersistedUserSessionsCount() { + return inComittedTransaction(session -> { + getRealm(session); + return session.getProvider(UserSessionPersisterProvider.class).getUserSessionsCount(false); + }); + } + + private RealmModel getRealm(KeycloakSession session) { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + return realm; + } + + private long countUserSessionsInRealm(KeycloakSession session) { + JpaUserSessionPersisterProvider sessionPersisterProvider = (JpaUserSessionPersisterProvider) session.getProvider(UserSessionPersisterProvider.class); + RealmModel realm = session.realms().getRealm(realmId); + return sessionPersisterProvider.getUserSessionsCountsByClients(realm, false).keySet().stream() + .flatMap(s -> sessionPersisterProvider.loadUserSessionsStream(realm, session.clients().getClientById(realm, s), false, 0, -1)) + .distinct().count(); + } + + private void cleanClientStorageComponents(KeycloakSession s, RealmModel realm) { + s.getContext().setRealm(realm); + s.roles().removeRoles(realm); + s.clientScopes().removeClientScopes(realm); + + realm.removeComponents(realm.getId()); + } + + protected static AuthenticatedClientSessionModel createClientSession(KeycloakSession session, String realmId, ClientModel client, UserSessionModel userSession, String redirect, String state) { + RealmModel realm = session.realms().getRealm(realmId); + AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, client, userSession); + clientSession.setRedirectUri(redirect); + if (state != null) clientSession.setNote(OIDCLoginProtocol.STATE_PARAM, state); + return clientSession; + } + + protected static UserSessionModel[] createSessions(KeycloakSession session, String realmId) { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + UserSessionModel[] sessions = new UserSessionModel[3]; + sessions[0] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + + createClientSession(session, realmId, realm.getClientByClientId("test-app"), sessions[0], "http://redirect", "state"); + createClientSession(session, realmId, realm.getClientByClientId("third-party"), sessions[0], "http://redirect", "state"); + + sessions[1] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.2", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + createClientSession(session, realmId, realm.getClientByClientId("test-app"), sessions[1], "http://redirect", "state"); + + sessions[2] = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user2"), "user2", "127.0.0.3", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + createClientSession(session, realmId, realm.getClientByClientId("test-app"), sessions[2], "http://redirect", "state"); + + return sessions; + } + + private void persistUserSession(KeycloakSession session, UserSessionModel userSession, boolean offline) { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + persister.createUserSession(userSession, offline); + for (AuthenticatedClientSessionModel clientSession : userSession.getAuthenticatedClientSessions().values()) { + persister.createClientSession(clientSession, offline); + } + } + + public static void assertSessionLoaded(List sessions, String id, UserModel user, String ipAddress, int started, int lastRefresh, String... clients) { + for (UserSessionModel session : sessions) { + if (session.getId().equals(id)) { + assertSession(session, user, ipAddress, started, lastRefresh, clients); + return; + } + } + Assert.fail("Session with ID " + id + " not found in the list"); + } + + private List loadPersistedSessionsPaginated(KeycloakSession session, boolean offline, int sessionsPerPage, int expectedPageCount, int expectedSessionsCount) { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + + int pageCount = 0; + boolean next = true; + List result = new ArrayList<>(); + String lastSessionId = "00000000-0000-0000-0000-000000000000"; + + while (next) { + List sess = persister + .loadUserSessionsStream(0, sessionsPerPage, offline, lastSessionId) + .toList(); + + if (sess.size() < sessionsPerPage) { + next = false; + + // We had at least some session + if (!sess.isEmpty()) { + pageCount++; + } + } else { + pageCount++; + + UserSessionModel lastSession = sess.get(sess.size() - 1); + lastSessionId = lastSession.getId(); + } + + result.addAll(sess); + } + + Assert.assertEquals(expectedPageCount, pageCount); + Assert.assertEquals(expectedSessionsCount, result.size()); + return result; + } + + public static void assertSession(UserSessionModel session, UserModel user, String ipAddress, int started, int lastRefresh, String... clients) { + assertEquals(user.getId(), session.getUser().getId()); + assertEquals(ipAddress, session.getIpAddress()); + assertEquals(user.getUsername(), session.getLoginUsername()); + assertEquals("form", session.getAuthMethod()); + assertTrue(session.isRememberMe()); + assertTrue(session.getStarted() >= started - 1 && session.getStarted() <= started + 1); + assertTrue(session.getLastSessionRefresh() >= lastRefresh - 1 && session.getLastSessionRefresh() <= lastRefresh + 1); + + String[] actualClients = new String[session.getAuthenticatedClientSessions().size()]; + int i = 0; + for (Map.Entry entry : session.getAuthenticatedClientSessions().entrySet()) { + String clientUUID = entry.getKey(); + AuthenticatedClientSessionModel clientSession = entry.getValue(); + Assert.assertEquals(clientUUID, clientSession.getClient().getId()); + actualClients[i] = clientSession.getClient().getClientId(); + i++; + } + + assertThat(actualClients, Matchers.arrayContainingInAnyOrder(clients)); + } + + public static void assertSessions(List actualSessions, String[] expectedSessionIds) { + String[] actual = new String[actualSessions.size()]; + for (int i = 0; i < actual.length; i++) { + actual[i] = actualSessions.get(i).getId(); + } + + assertThat(actual, Matchers.arrayContainingInAnyOrder(expectedSessionIds)); + } + + private record UserSessionCount(int database, int cache) {} +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionProviderModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionProviderModelTest.java new file mode 100644 index 00000000..4f9737fc --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionProviderModelTest.java @@ -0,0 +1,237 @@ +package tech.ydb.keycloak.testsuite.session; + +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; +import org.keycloak.infinispan.util.InfinispanUtils; +import org.keycloak.models.*; +import org.keycloak.models.utils.KeycloakModelUtils; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.*; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.*; +import static tech.ydb.keycloak.testsuite.session.UserSessionPersisterProviderTest.createClients; +import static tech.ydb.keycloak.testsuite.session.UserSessionPersisterProviderTest.createSessions; + +@RequireProvider(UserSessionProvider.class) +@RequireProvider(UserProvider.class) +@RequireProvider(RealmProvider.class) +public class UserSessionProviderModelTest extends KeycloakModelTest { + + private String realmId; + private KeycloakSession kcSession; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "test"); + s.getContext().setRealm(realm); + realm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realm.setSsoSessionIdleTimeout(1800); + realm.setSsoSessionMaxLifespan(36000); + realm.setClientSessionIdleTimeout(500); + this.realmId = realm.getId(); + this.kcSession = s; + + s.users().addUser(realm, "user1").setEmail("user1@localhost"); + s.users().addUser(realm, "user2").setEmail("user2@localhost"); + + createClients(s, realm); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testMultipleSessionsRemovalInOneTransaction() { + UserSessionModel[] origSessions = inComittedTransaction(session -> { return createSessions(session, realmId); }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + UserSessionModel userSession = session.sessions().getUserSession(realm, origSessions[0].getId()); + Assert.assertEquals(origSessions[0], userSession); + + userSession = session.sessions().getUserSession(realm, origSessions[1].getId()); + Assert.assertEquals(origSessions[1], userSession); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + session.sessions().removeUserSession(realm, session.sessions().getUserSession(realm, origSessions[0].getId())); + session.sessions().removeUserSession(realm, session.sessions().getUserSession(realm, origSessions[1].getId())); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + + UserSessionModel userSession = session.sessions().getUserSession(realm, origSessions[0].getId()); + Assert.assertNull(userSession); + + userSession = session.sessions().getUserSession(realm, origSessions[1].getId()); + Assert.assertNull(userSession); + }); + } + + @Test + public void testTransientUserSessionIsNotPersisted() { + String id = inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + UserSessionModel userSession = session.sessions().createUserSession(KeycloakModelUtils.generateId(), realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", false, null, null, UserSessionModel.SessionPersistenceState.TRANSIENT); + + ClientModel testApp = realm.getClientByClientId("test-app"); + session.sessions().createClientSession(realm, testApp, userSession); + + // assert the client sessions are present + assertThat(session.sessions().getClientSession(userSession, testApp, false), notNullValue()); + return userSession.getId(); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + UserSessionModel userSession = session.sessions().getUserSession(realm, id); + + // in new transaction transient session should not be present + assertThat(userSession, nullValue()); + }); + } + + @Test + public void testClientSessionIsNotPersistedForTransientUserSession() { + UserSessionModel userSession = inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + UserSessionModel us = session.sessions().createUserSession(null, realm, session.users().getUserByUsername(realm, "user1"), "user1", "127.0.0.1", "form", false, null, null, UserSessionModel.SessionPersistenceState.TRANSIENT); + ClientModel testApp = realm.getClientByClientId("test-app"); + session.sessions().createClientSession(realm, testApp, us); + + // assert the client sessions are present + assertThat(session.sessions().getClientSession(us, testApp, false), notNullValue()); + return us; + }); + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + ClientModel testApp = realm.getClientByClientId("test-app"); + // in new transaction transient session should not be present + assertThat(session.sessions().getClientSession(userSession, testApp, false), nullValue()); + }); + } + + @Test + public void testCreateUserSessionsParallel() throws InterruptedException { + Set userSessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>()); + CountDownLatch latch = new CountDownLatch(4); + + inIndependentFactories(4, 30, () -> { + withRealm(realmId, (session, realm) -> { + UserModel user = session.users().getUserByUsername(realm, "user1"); + UserSessionModel userSession = session.sessions().createUserSession(null, realm, user, "user1", "", "", false, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + userSessionIds.add(userSession.getId()); + + latch.countDown(); + + return null; + }); + + // wait for other nodes to finish + try { + latch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + assertThat(userSessionIds, Matchers.iterableWithSize(4)); + + // wait a bit to allow replication + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + withRealm(realmId, (session, realm) -> { + userSessionIds.forEach(id -> Assert.assertNotNull(session.sessions().getUserSession(realm, id))); + + return null; + }); + }); + } + + @Test + public void testStreamsMarshalling() throws InterruptedException { + Assume.assumeTrue(InfinispanUtils.isEmbeddedInfinispan()); + closeKeycloakSessionFactory(); + var clusterSize = 4; + var barrier = new CyclicBarrier(clusterSize); + + inIndependentFactories(clusterSize, 30, () -> { + // populate the cache + withRealmConsumer(realmId, (keycloakSession, realm) -> { + var user = keycloakSession.users().getUserByUsername(realm, "user1"); + var client = realm.getClientByClientId("test-app"); + assertNotNull(user); + assertNotNull(client); + var userSession = keycloakSession.sessions().createUserSession(null, realm, user, "user1", "127.0.0.1", "form", true, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT); + assertNotNull(userSession); + var clientSession = keycloakSession.sessions().createClientSession(realm, client, userSession); + assertNotNull(clientSession); + }); + + try { + barrier.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (BrokenBarrierException | TimeoutException e) { + throw new RuntimeException(e); + } + + withRealmConsumer(realmId, (keycloakSession, realm) -> { + var user = keycloakSession.users().getUserByUsername(realm, "user1"); + assertNotNull(user); + + var client = realm.getClientByClientId("test-app"); + assertNotNull(client); + + var activeClientSessionsStats = keycloakSession.sessions().getActiveClientSessionStats(realm, false); + assertNotNull(activeClientSessionsStats); + assertEquals(1, activeClientSessionsStats.size()); + assertTrue(activeClientSessionsStats.containsKey(client.getId())); + assertEquals(4L, (long) activeClientSessionsStats.get(client.getId())); + + var userSessions = keycloakSession.sessions().getUserSessionsStream(realm, user).toList(); + assertNotNull(userSessions); + assertEquals(4, userSessions.size()); + + // sync everybody here since we are going to remove everything. + try { + barrier.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (BrokenBarrierException | TimeoutException e) { + throw new RuntimeException(e); + } + + keycloakSession.sessions().removeUserSessions(realm, user); + }); + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/singleUseObject/SingleUseObjectModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/singleUseObject/SingleUseObjectModelTest.java new file mode 100644 index 00000000..59c33057 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/singleUseObject/SingleUseObjectModelTest.java @@ -0,0 +1,293 @@ + +package tech.ydb.keycloak.testsuite.singleUseObject; + +import org.hamcrest.Matchers; +import org.infinispan.client.hotrod.RemoteCache; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.common.util.Time; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.infinispan.util.InfinispanUtils; +import org.keycloak.models.*; +import org.keycloak.models.sessions.infinispan.InfinispanSingleUseObjectProviderFactory; +import org.keycloak.models.sessions.infinispan.entities.SingleUseObjectValueEntity; +import org.keycloak.services.scheduled.ClearExpiredRevokedTokens; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.hamcrest.MatcherAssert.assertThat; + +@RequireProvider(SingleUseObjectProvider.class) +public class SingleUseObjectModelTest extends KeycloakModelTest { + + private String realmId; + + private String userId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realmId = realm.getId(); + UserModel user = s.users().addUser(realm, "user"); + userId = user.getId(); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Test + public void testActionTokens() { + DefaultActionTokenKey key = withRealm(realmId, (session, realm) -> { + SingleUseObjectProvider singleUseObjectProvider = session.singleUseObjects(); + int time = Time.currentTime(); + DefaultActionTokenKey actionTokenKey = new DefaultActionTokenKey(userId, UUID.randomUUID().toString(), time + 60, null); + Map notes = new HashMap<>(); + notes.put("foo", "bar"); + singleUseObjectProvider.put(actionTokenKey.serializeKey(), actionTokenKey.getExp() - time, notes); + return actionTokenKey; + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseObjectProvider = session.singleUseObjects(); + Map notes = singleUseObjectProvider.get(key.serializeKey()); + Assert.assertNotNull(notes); + Assert.assertEquals("bar", notes.get("foo")); + + notes = singleUseObjectProvider.remove(key.serializeKey()); + Assert.assertNotNull(notes); + Assert.assertEquals("bar", notes.get("foo")); + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseObjectProvider = session.singleUseObjects(); + Map notes = singleUseObjectProvider.get(key.serializeKey()); + Assert.assertNull(notes); + + notes = new HashMap<>(); + notes.put("foo", "bar"); + singleUseObjectProvider.put(key.serializeKey(), key.getExp() - Time.currentTime(), notes); + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseObjectProvider = session.singleUseObjects(); + Map notes = singleUseObjectProvider.get(key.serializeKey()); + Assert.assertNotNull(notes); + Assert.assertEquals("bar", notes.get("foo")); + }); + + setTimeOffset(70); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseObjectProvider = session.singleUseObjects(); + Map notes = singleUseObjectProvider.get(key.serializeKey()); + notes = singleUseObjectProvider.get(key.serializeKey()); + Assert.assertNull(notes); + }); + } + + @Test + public void testSingleUseStore() { + String key = UUID.randomUUID().toString(); + Map notes = new HashMap<>(); + notes.put("foo", "bar"); + + Map notes2 = new HashMap<>(); + notes2.put("baf", "meow"); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + Assert.assertFalse(singleUseStore.replace(key, notes2)); + + singleUseStore.put(key, 60, notes); + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + Map actualNotes = singleUseStore.get(key); + Assert.assertEquals(notes, actualNotes); + + Assert.assertTrue(singleUseStore.replace(key, notes2)); + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + Map actualNotes = singleUseStore.get(key); + Assert.assertEquals(notes2, actualNotes); + + Assert.assertFalse(singleUseStore.putIfAbsent(key, 60)); + + Assert.assertEquals(notes2, singleUseStore.remove(key)); + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + Assert.assertTrue(singleUseStore.putIfAbsent(key, 60)); + }); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + Map actualNotes = singleUseStore.get(key); + assertThat(actualNotes, Matchers.anEmptyMap()); + }); + + setTimeOffset(70); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + Assert.assertNull(singleUseStore.get(key)); + }); + } + + @Test + public void testRevokedTokenIsPresentAfterRestartAndEventuallyExpires() { + String revokedKey = UUID.randomUUID() + SingleUseObjectProvider.REVOKED_KEY; + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + singleUseStore.put(revokedKey, 60, Collections.emptyMap()); + }); + + // Run again to ensure revocation can happen multiple times + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + singleUseStore.put(revokedKey, 60, Collections.emptyMap()); + }); + + // simulate restart + removeRevokedTokenFromRemoteCache(revokedKey); + reinitializeKeycloakSessionFactory(); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + assertThat(singleUseStore.contains(revokedKey), Matchers.is(true)); + }); + + setTimeOffset(120); + + // simulate restart + removeRevokedTokenFromRemoteCache(revokedKey); + reinitializeKeycloakSessionFactory(); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + // not loaded as it is too old + assertThat(singleUseStore.contains(revokedKey), Matchers.is(false)); + + // remove it from the database + new ClearExpiredRevokedTokens().run(session); + }); + + setTimeOffset(0); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + // not loaded as it has been removed from the database + assertThat(singleUseStore.contains(revokedKey), Matchers.is(false)); + }); + + } + + @Test + public void testCluster() throws InterruptedException { + AtomicInteger index = new AtomicInteger(); + CountDownLatch afterFirstNodeLatch = new CountDownLatch(1); + CountDownLatch afterDeleteLatch = new CountDownLatch(1); + CountDownLatch clusterJoined = new CountDownLatch(4); + CountDownLatch replicationDone = new CountDownLatch(4); + + String key = UUID.randomUUID().toString(); + AtomicReference actionTokenKey = new AtomicReference<>(); + Map notes = new HashMap<>(); + notes.put("foo", "bar"); + + inIndependentFactories(4, 60, () -> { + log.debug("Joining the cluster"); + clusterJoined.countDown(); + awaitLatch(clusterJoined); + log.debug("Cluster joined"); + + if (index.incrementAndGet() == 1) { + actionTokenKey.set(withRealm(realmId, (session, realm) -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + singleUseStore.put(key, 60, notes); + + int time = Time.currentTime(); + DefaultActionTokenKey atk = new DefaultActionTokenKey(userId, UUID.randomUUID().toString(), time + 60, null); + singleUseStore.put(atk.serializeKey(), atk.getExp() - time, notes); + + return atk.serializeKey(); + })); + + afterFirstNodeLatch.countDown(); + } + awaitLatch(afterFirstNodeLatch); + + // check if single-use object/action token is available on all nodes + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + eventually(() -> "key not found: " + key, () -> singleUseStore.get(key) != null); + eventually(() -> "key not found: " + actionTokenKey.get(), () -> singleUseStore.get(actionTokenKey.get()) != null); + replicationDone.countDown(); + }); + + awaitLatch(replicationDone); + + // remove objects on one node + if (index.incrementAndGet() == 5) { + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + singleUseStore.remove(key); + singleUseStore.remove(actionTokenKey.get()); + }); + + afterDeleteLatch.countDown(); + } + + awaitLatch(afterDeleteLatch); + + // check if single-use object/action token is removed + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + + eventually(() -> "key found: " + key, () -> singleUseStore.get(key) == null); + eventually(() -> "key found: " + actionTokenKey.get(), () -> singleUseStore.get(actionTokenKey.get()) == null); + }); + }); + } + + private void awaitLatch(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private void removeRevokedTokenFromRemoteCache(String revokedKey) { + if (!InfinispanUtils.isRemoteInfinispan()) { + return; + } + inComittedTransaction(session -> { + RemoteCache cache = session.getProvider(InfinispanConnectionProvider.class).getRemoteCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE); + // remove loaded key to enable preloading from database + cache.remove(InfinispanSingleUseObjectProviderFactory.LOADED); + // remote the token + cache.remove(revokedKey); + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/transaction/StorageTransactionTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/transaction/StorageTransactionTest.java new file mode 100644 index 00000000..c9bbd80e --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/transaction/StorageTransactionTest.java @@ -0,0 +1,100 @@ + +package tech.ydb.keycloak.testsuite.transaction; + +import org.junit.Test; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RealmProvider; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; +import tech.ydb.keycloak.testsuite.util.TransactionController; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; + +@RequireProvider(RealmProvider.class) +public class StorageTransactionTest extends KeycloakModelTest { + + private String realmId; + + @Override + protected void createEnvironment(KeycloakSession s) { + RealmModel r = s.realms().createRealm("1"); + s.getContext().setRealm(r); + r.setDefaultRole(s.roles().addRealmRole(r, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + r.getName())); + r.setAttribute("k1", "v1"); + r.setSsoSessionIdleTimeout(1000); + r.setSsoSessionMaxLifespan(2000); + + realmId = r.getId(); + } + + @Override + protected void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Override + protected boolean isUseSameKeycloakSessionFactoryForAllThreads() { + return true; + } + + @Test + public void testTwoTransactionsSequentially() throws Exception { + try (TransactionController tx1 = new TransactionController(getFactory()); + TransactionController tx2 = new TransactionController(getFactory())) { + tx1.begin(); + assertThat( + tx1.runStep(session -> { + session.realms().getRealm(realmId).setAttribute("k2", "v1"); + return session.realms().getRealm(realmId).getAttribute("k2"); + }), equalTo("v1")); + tx1.commit(); + + tx2.begin(); + assertThat( + tx2.runStep(session -> session.realms().getRealm(realmId).getAttribute("k2")), + equalTo("v1")); + tx2.commit(); + } + } + + @Test + public void testRepeatableRead() throws Exception { + try (TransactionController tx1 = new TransactionController(getFactory()); + TransactionController tx2 = new TransactionController(getFactory()); + TransactionController tx3 = new TransactionController(getFactory())) { + + tx1.begin(); + tx2.begin(); + tx3.begin(); + + // Read original value in tx1 + assertThat( + tx1.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")), + equalTo("v1")); + + // change value to new in tx2 + tx2.runStep(session -> { + session.realms().getRealm(realmId).setAttribute("k1", "v2"); + return null; + }); + tx2.commit(); + + // tx1 should still return the value that already read + assertThat( + tx1.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")), + equalTo("v1")); + + // tx3 should return the new value + assertThat( + tx3.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")), + equalTo("v2")); + tx1.commit(); + tx3.commit(); + } + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/user/UserModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/user/UserModelTest.java new file mode 100644 index 00000000..3c1f2878 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/user/UserModelTest.java @@ -0,0 +1,82 @@ +package tech.ydb.keycloak.testsuite.user; + +import org.hamcrest.Matchers; +import org.junit.Test; +import org.keycloak.models.*; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +@RequireProvider(UserProvider.class) +@RequireProvider(RealmProvider.class) +public class UserModelTest extends KeycloakModelTest { + + protected static final int NUM_GROUPS = 100; + + private String realmId; + private final List groupIds = new ArrayList<>(NUM_GROUPS); + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "realm"); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + this.realmId = realm.getId(); + + IntStream.range(0, NUM_GROUPS).forEach(i -> { + groupIds.add(s.groups().createGroup(realm, "group-" + i).getId()); + }); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.realms().removeRealm(realmId); + } + + @Override + protected boolean isUseSameKeycloakSessionFactoryForAllThreads() { + return true; + } + + private Void addRemoveUser(KeycloakSession session, int i) { + RealmModel realm = session.realms().getRealmByName("realm"); + session.getContext().setRealm(realm); + + UserModel user = session.users().addUser(realm, "user-" + i); + + IntStream.range(0, NUM_GROUPS / 20).forEach(gIndex -> { + user.joinGroup(session.groups().getGroupById(realm, groupIds.get((i + gIndex) % NUM_GROUPS))); + }); + + final UserModel obtainedUser = session.users().getUserById(realm, user.getId()); + + assertThat(obtainedUser, Matchers.notNullValue()); + assertThat(obtainedUser.getUsername(), is("user-" + i)); + Set userGroupIds = obtainedUser.getGroupsStream().map(GroupModel::getName).collect(Collectors.toSet()); + assertThat(userGroupIds, hasSize(NUM_GROUPS / 20)); + assertThat(userGroupIds, hasItem("group-" + i)); + assertThat(userGroupIds, hasItem("group-" + (i - 1 + (NUM_GROUPS / 20)) % NUM_GROUPS)); + + assertTrue(session.users().removeUser(realm, user)); + assertFalse(session.users().removeUser(realm, user)); + assertNull(session.users().getUserByUsername(realm, user.getUsername())); + + return null; + } + + @Test + public void testAddRemoveUser() { + inRolledBackTransaction(1, this::addRemoveUser); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/util/TransactionController.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/util/TransactionController.java new file mode 100644 index 00000000..15d33416 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/util/TransactionController.java @@ -0,0 +1,115 @@ +package tech.ydb.keycloak.testsuite.util; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.KeycloakTransactionManager; + +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * This controller adds possibility to manually control more transaction within + * one test case. + *

+ * It uses ExecutorService to run each transaction in a separate thread. This + * is necessary, for example, for pessimistic locking in HotRod as the locks + * needs to be reentrant by the same thread. If this is running in one thread + * the pessimistic locking does not work as all transactions are able to + * acquire the same lock repeatedly. + */ +public class TransactionController implements AutoCloseable { + private final AtomicReference session = new AtomicReference<>(); + private final ExecutorService executor; + private final AtomicReference threadName = new AtomicReference<>(); + + public TransactionController(KeycloakSessionFactory sessionFactory) { + executor = Executors.newSingleThreadExecutor(); + CountDownLatch latch = new CountDownLatch(1); + executor.execute(() -> { + threadName.set(Thread.currentThread().getName()); + latch.countDown(); + }); + try { + if (!latch.await(1, TimeUnit.MINUTES)) { + throw new RuntimeException("Initialization of TransactionController timed out"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + executeAndWaitUntilFinished(() -> threadName.set(Thread.currentThread().getName())); + executeAndWaitUntilFinished(() -> session.set(sessionFactory.create())); + } + + public void begin() { + executeAndWaitUntilFinished(() -> getTransactionManager().begin()); + } + + public void commit() { + executeAndWaitUntilFinished(() -> getTransactionManager().commit()); + } + + public void rollback() { + executeAndWaitUntilFinished(() -> getTransactionManager().rollback()); + } + + public R runStep(Function task) { + AtomicReference result = new AtomicReference<>(); + executeAndWaitUntilFinished(() -> result.set(task.apply(session.get()))); + return result.get(); + } + + private KeycloakTransactionManager getTransactionManager() { + return session.get().getTransactionManager(); + } + + private void executeAndWaitUntilFinished(Runnable runnable) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference exception = new AtomicReference<>(); + executor.execute(() -> { + if (!Objects.equals(threadName.get(), Thread.currentThread().getName())) { + throw new RuntimeException("Execution running in different thread"); + } + try { + runnable.run(); + } catch (RuntimeException ex) { + exception.set(ex); + } finally { + latch.countDown(); + } + }); + try { + if (!latch.await(1, TimeUnit.MINUTES)) { + throw new RuntimeException("Waiting for the operation to finish timed out"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + if (exception.get() != null) { + throw exception.get(); + } + } + + @Override + public void close() throws Exception { + // Shutdown executor + executor.shutdown(); + try { + // Wait until it is terminated + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + // Shutdown forcefully + executor.shutdownNow(); + } + } catch (InterruptedException ex) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/keycloak-ydb-extension/test/src/test/resources/log4j.properties b/keycloak-ydb-extension/test/src/test/resources/log4j.properties new file mode 100644 index 00000000..dbbf4539 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/resources/log4j.properties @@ -0,0 +1,75 @@ +# +# Copyright 2016 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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. +# + +log4j.rootLogger=info, keycloak + +log4j.appender.keycloak=org.apache.log4j.ConsoleAppender +log4j.appender.keycloak.layout=org.apache.log4j.EnhancedPatternLayout +keycloak.testsuite.logging.pattern=%d{HH:mm:ss,SSS} %-5p [%c] (%t) %m%n +log4j.appender.keycloak.layout.ConversionPattern=${keycloak.testsuite.logging.pattern} + +# Logging with "info" when running test from IDE, but disabled when running test with "mvn" . Both cases can be overriden by use system property "keycloak.logging.level" (eg. -Dkeycloak.logging.level=debug ) +log4j.logger.org.keycloak=${keycloak.logging.level:info} + +keycloak.testsuite.logging.level=debug +log4j.logger.org.keycloak.testsuite=${keycloak.testsuite.logging.level} + +# Logging with "info" when running test from IDE, but disabled when running test with "mvn" . Both cases can be overriden by use system property "keycloak.logging.level" (eg. -Dkeycloak.logging.level=debug ) +# log4j.logger.org.hibernate=debug + +# Enable to view loaded SPI and Providers + log4j.logger.org.keycloak.services.DefaultKeycloakSessionFactory=debug + log4j.logger.org.keycloak.provider.ProviderManager=debug +# log4j.logger.org.keycloak.provider.FileSystemProviderLoaderFactory=debug + +# Liquibase updates logged with "info" by default. Logging level can be changed by system property "keycloak.liquibase.logging.level" +keycloak.liquibase.logging.level=info +log4j.logger.org.keycloak.connections.jpa.updater.liquibase=${keycloak.liquibase.logging.level} +log4j.logger.org.keycloak.connections.jpa.DefaultJpaConnectionProviderFactory=debug + +# Enable to log short stack traces for log entries enabled with StackUtil.getShortStackTrace() calls +#log4j.logger.org.keycloak.STACK_TRACE=trace + +#log4j.logger.org.keycloak.models.sessions.infinispan=trace +keycloak.infinispan.logging.level=info +log4j.logger.org.keycloak.cluster.infinispan=${keycloak.infinispan.logging.level} +log4j.logger.org.keycloak.connections.infinispan=${keycloak.infinispan.logging.level} +log4j.logger.org.keycloak.keys.infinispan=${keycloak.infinispan.logging.level} +log4j.logger.org.keycloak.models.cache.infinispan=${keycloak.infinispan.logging.level} +log4j.logger.org.keycloak.models.sessions.infinispan=${keycloak.infinispan.logging.level} + +log4j.logger.org.infinispan.CLUSTER=warn +log4j.logger.org.infinispan.server.hotrod=info +log4j.logger.org.infinispan.client.hotrod.impl=info +log4j.logger.org.infinispan.client.hotrod.event.impl=info + +log4j.logger.org.infinispan.client.hotrod.impl.query.RemoteQuery=error + +# avoid logging INFO-message "ignoring the message MessageType : UNBIND_REQUEST" very often +log4j.logger.org.apache.directory.server.ldap.handlers.LdapRequestHandler=warn + +log4j.logger.org.keycloak.executors=info + +#log4j.logger.org.infinispan.expiration.impl.ClusterExpirationManager=trace + +## Enable SQL debugging +# Enable logs the SQL statements +#log4j.logger.org.hibernate.SQL=debug + +# Enable logs the JDBC parameters passed to a query +#log4j.logger.org.hibernate.orm.jdbc.bind=trace + diff --git a/keycloak-ydb-extension/test/src/test/resources/test-ispn.xml b/keycloak-ydb-extension/test/src/test/resources/test-ispn.xml new file mode 100644 index 00000000..b305b4cd --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/resources/test-ispn.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +