From 49ddbc79df5cccf1239f73b7fea12b99b91da1e2 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 1 Feb 2026 14:38:10 +0300 Subject: [PATCH 001/120] feat: Add keycloak-ydb-extension module. Implement storing keycloak realm data in ydb --- keycloak-ydb-extension/.gitignore | 4 + keycloak-ydb-extension/README.md | 12 + .../docker/docker-compose.yml | 61 +++++ keycloak-ydb-extension/pom.xml | 232 ++++++++++++++++++ .../run-keycloack-with-ydb.sh | 7 + .../ydb/keycloak/config/ProviderPriority.kt | 5 + .../tech/ydb/keycloak/config/YdbProfile.kt | 9 + .../DefaultYdbConnectionProviderFactory.kt | 80 ++++++ .../connection/YdbConnectionProvider.kt | 8 + .../YdbConnectionProviderFactory.kt | 5 + .../keycloak/connection/YdbConnectionSpi.kt | 17 ++ .../keycloak/migration/YdbMigrationManager.kt | 32 +++ .../ydb/keycloak/realm/YdbRealmProvider.kt | 10 + .../keycloak/realm/YdbRealmProviderFactory.kt | 48 ++++ .../keycloak/transaction/YdbJpaTransaction.kt | 33 +++ .../ydb/keycloak/util/EntityManagerUtils.kt | 144 +++++++++++ .../tech/ydb/keycloak/util/MigrationUtils.kt | 20 ++ .../org.keycloak.models.RealmProviderFactory | 1 + .../services/org.keycloak.provider.Spi | 1 + ...ak.connection.YdbConnectionProviderFactory | 1 + .../17-12-2025-21-07-create-realm-table.sql | 60 +++++ ...25-21-29-create-realm-attributes-table.sql | 9 + ...reate-realm-required-credentials-table.sql | 10 + ...20-create-realm_events_listeners-table.sql | 8 + ...0-21-create-realm-default-groups-table.sql | 8 + ...create-realm-enabled-event-types-table.sql | 8 + ...00-29-create-realm-localizations-table.sql | 8 + ...5-00-30-create-realm-smtp-config-table.sql | 8 + ...1-create-realm-supported-locales-table.sql | 8 + ...0-34-create-default-client-scope-table.sql | 10 + ...00-36-create-authentication-flow-table.sql | 13 + ...-create-authentication-execution-table.sql | 17 ++ ...0-42-create-authenticator-config-table.sql | 9 + ...-create-required-action-provider-table.sql | 14 ++ ...45-create-required-action-config-table.sql | 8 + ...reate-authenticator-config-entry-table.sql | 8 + ...0-12-2025-00-47-create-component-table.sql | 14 ++ ...25-00-48-create-component-config-table.sql | 10 + .../20-12-2025-01-00-create-client-table.sql | 32 +++ ...2-2025-01-01-create-event-entity-table.sql | 18 ++ .../resources/ydb/db.changelog-master.xml | 8 + 41 files changed, 1018 insertions(+) create mode 100644 keycloak-ydb-extension/.gitignore create mode 100644 keycloak-ydb-extension/README.md create mode 100644 keycloak-ydb-extension/docker/docker-compose.yml create mode 100644 keycloak-ydb-extension/pom.xml create mode 100644 keycloak-ydb-extension/run-keycloack-with-ydb.sh create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml 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..4e981983 --- /dev/null +++ b/keycloak-ydb-extension/README.md @@ -0,0 +1,12 @@ +# Keycloak YDB extension + +## Overview +Keycloak extension to store data using YDB + + +## Getting Started +To store keycloak data in ydb. Mount jar build of this project to keycloak. + +### Local development + +`run-keycloack-with-ydb.sh` script builds project and mount jar of this project to keycloak. \ No newline at end of file diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml new file mode 100644 index 00000000..1a38fd9f --- /dev/null +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -0,0 +1,61 @@ +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", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls"] + interval: 10s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:26.4.7 + volumes: + - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + environment: + # YDB Configuration + - KC_COMMUNITY_DATASTORE_YDB_ENABLED=true + - KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local + - KC_SPI_YDB_CONNECTION_DEFAULT_CREATE_SCHEMA=true + # Keycloak Admin + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + entrypoint: /opt/keycloak/bin/kc.sh + command: > + -v start-dev + --cache=local + --features-disabled=authorization,admin-fine-grained-authz,organization + ports: + - 9090:8080 + networks: + - keycloak + depends_on: + ydb: + condition: service_started \ 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..3de2bca7 --- /dev/null +++ b/keycloak-ydb-extension/pom.xml @@ -0,0 +1,232 @@ + + + 4.0.0 + + tech.ydb + keycloak-ydb-extension + 1.0-SNAPSHOT + + + 21 + 21 + 21 + UTF-8 + + 2.2.21 + official + 21 + + 26.4.7 + + 2.3.20 + + 7.0.2 + 1.5.1 + + + + 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-surefire-plugin + 3.5.4 + + + maven-failsafe-plugin + 3.5.4 + + + org.apache.maven.plugins + 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.junit + junit-bom + 6.0.1 + 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 + ${hibernate.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 + + + \ No newline at end of file diff --git a/keycloak-ydb-extension/run-keycloack-with-ydb.sh b/keycloak-ydb-extension/run-keycloack-with-ydb.sh new file mode 100644 index 00000000..fe3724ab --- /dev/null +++ b/keycloak-ydb-extension/run-keycloack-with-ydb.sh @@ -0,0 +1,7 @@ +mvn clean package + +mkdir -p docker/providers + +cp target/keycloak-ydb-extension-1.0-SNAPSHOT.jar docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + +docker-compose -f docker/docker-compose.yml up -d \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt new file mode 100644 index 00000000..342b7066 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt @@ -0,0 +1,5 @@ +package tech.ydb.keycloak.config + +object ProviderPriority { + const val PROVIDER_PRIORITY = 1 +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt new file mode 100644 index 00000000..170f3bc2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt @@ -0,0 +1,9 @@ +package tech.ydb.keycloak.config + +object YdbProfile { + private const val ENV_YDB_PROFILE_ENABLED: String = "KC_COMMUNITY_DATASTORE_YDB_ENABLED" + private const val PROP_YDB_PROFILE_ENABLED: String = "kc.community.datastore.ydb.enabled" + + val IS_YDB_PROFILE_ENABLED = System.getenv(ENV_YDB_PROFILE_ENABLED).toBoolean() + || System.getProperty(PROP_YDB_PROFILE_ENABLED).toBoolean() +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt new file mode 100644 index 00000000..895bb871 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt @@ -0,0 +1,80 @@ +package tech.ydb.keycloak.connection + +import tech.ydb.keycloak.migration.YdbMigrationManager.migrate +import com.zaxxer.hikari.HikariDataSource +import jakarta.persistence.EntityManager +import jakarta.persistence.EntityManagerFactory +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.connections.jpa.support.EntityManagerProxy +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.provider.EnvironmentDependentProviderFactory +import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED +import tech.ydb.keycloak.transaction.YdbJpaTransaction +import tech.ydb.keycloak.util.EntityManagerUtils.createEntityManagerFactory +import tech.ydb.keycloak.util.hikariDataSource + +class DefaultYdbConnectionProviderFactory : YdbConnectionProviderFactory, + EnvironmentDependentProviderFactory { + + private val logger: Logger = Logger.getLogger(DefaultYdbConnectionProviderFactory::class.java) + + private lateinit var dataSource: HikariDataSource + private lateinit var entityManagerFactory: EntityManagerFactory + + override fun create(session: KeycloakSession): YdbConnectionProvider = + createYdbConnectionProvider(session) + + override fun init(scope: Config.Scope) { + val jdbcUrl = scope["jdbcUrl"] + val poolSize = scope.getInt("poolSize", 10) + val connectionTimeout = scope.getLong("connectionTimeout", 5000L) // todo review + val showSql = scope.getBoolean("showSql", false) + val formatSql = scope.getBoolean("formatSql", true) + + dataSource = hikariDataSource(jdbcUrl, poolSize) + + entityManagerFactory = createEntityManagerFactory(dataSource, showSql, formatSql) + + logger.info("YDB connection pool, JOOQ DSLContext and EntityManager configured successfully") + + migrate(dataSource) + } + + override fun postInit(factory: KeycloakSessionFactory) { + // no operations + } + + override fun close() { + entityManagerFactory.close() + dataSource.close() + } + + override fun getId(): String = PROVIDER_ID + + override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED + + private fun createYdbConnectionProvider(session: KeycloakSession): YdbConnectionProvider { + return object : YdbConnectionProvider { + override val entityManager: EntityManager = createEntityManager(session) + + override fun close() { + entityManager.close() + } + } + } + + private fun createEntityManager(session: KeycloakSession): EntityManager { + val em = entityManagerFactory.createEntityManager() + + val tx = YdbJpaTransaction(em) + session.transactionManager.enlist(tx) + + return EntityManagerProxy.create(session, em, true) + } + + private companion object { + private const val PROVIDER_ID: String = "default" + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt new file mode 100644 index 00000000..14d5031c --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt @@ -0,0 +1,8 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import org.keycloak.provider.Provider + +interface YdbConnectionProvider : Provider { + val entityManager: EntityManager +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt new file mode 100644 index 00000000..c7ba02de --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt @@ -0,0 +1,5 @@ +package tech.ydb.keycloak.connection + +import org.keycloak.provider.ProviderFactory + +interface YdbConnectionProviderFactory : ProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt new file mode 100644 index 00000000..1e7e341e --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt @@ -0,0 +1,17 @@ +package tech.ydb.keycloak.connection + +import org.keycloak.provider.Spi + +class YdbConnectionSpi : Spi { + override fun isInternal(): Boolean = true + + override fun getName() = NAME + + override fun getProviderClass() = YdbConnectionProvider::class.java + + override fun getProviderFactoryClass() = YdbConnectionProviderFactory::class.java + + companion object { + private const val NAME: String = "ydbConnection" + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt new file mode 100644 index 00000000..8b7fc497 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt @@ -0,0 +1,32 @@ +package tech.ydb.keycloak.migration + +import liquibase.Liquibase +import liquibase.database.jvm.JdbcConnection +import liquibase.exception.LiquibaseException +import liquibase.resource.ClassLoaderResourceAccessor +import org.jboss.logging.Logger +import java.sql.SQLException +import javax.sql.DataSource + +object YdbMigrationManager { + private const val CHANGELOG_FILE: String = "ydb/db.changelog-master.xml" + + private val logger = Logger.getLogger(YdbMigrationManager::class.java) + + fun migrate(dataSource: DataSource) { + logger.info("Starting YDB migrations using Liquibase...") + + try { + dataSource.connection.use { connection -> + Liquibase(CHANGELOG_FILE, ClassLoaderResourceAccessor(), JdbcConnection(connection)).use { liquibase -> + liquibase.update() + logger.info("YDB migrations completed successfully") + } + } + } catch (e: LiquibaseException) { + logger.error("Failed to execute YDB migrations", e) + + throw SQLException("Failed to execute YDB migrations", e) + } + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt new file mode 100644 index 00000000..47ad8878 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -0,0 +1,10 @@ +package tech.ydb.keycloak.realm + +import jakarta.persistence.EntityManager +import org.keycloak.models.KeycloakSession +import org.keycloak.models.jpa.JpaRealmProvider + +class YdbRealmProvider( + session: KeycloakSession, + entityManager: EntityManager, +) : JpaRealmProvider(session, entityManager, null, null) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt new file mode 100644 index 00000000..935a0981 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -0,0 +1,48 @@ +package tech.ydb.keycloak.realm + +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.models.RealmProviderFactory +import org.keycloak.provider.EnvironmentDependentProviderFactory +import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED +import tech.ydb.keycloak.connection.YdbConnectionProvider + +class YdbRealmProviderFactory() : RealmProviderFactory, EnvironmentDependentProviderFactory { + + private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) + + override fun create(session: KeycloakSession): YdbRealmProvider { + val provider = session.getProvider(YdbConnectionProvider::class.java)?.let { + YdbRealmProvider(session, it.entityManager) + } ?: error("YdbConnectionProvider is not configured") + + 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 = ID + + override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED + + override fun order(): Int = PROVIDER_PRIORITY + 1 + + private companion object { + private const val ID = "ydb-realm-provider-factory" + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt new file mode 100644 index 00000000..da876df5 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt @@ -0,0 +1,33 @@ +package tech.ydb.keycloak.transaction + +import jakarta.persistence.EntityManager +import org.keycloak.models.KeycloakTransaction + +class YdbJpaTransaction( + private val em: EntityManager +) : KeycloakTransaction { + + override fun begin() { + em.transaction.begin() + } + + override fun commit() { + em.transaction.commit() + } + + override fun rollback() { + if (em.transaction.isActive) { + em.transaction.rollback() + } + } + + override fun setRollbackOnly() { + if (em.transaction.isActive) { + em.transaction.setRollbackOnly() + } + } + + override fun getRollbackOnly(): Boolean = em.transaction.rollbackOnly + + override fun isActive(): Boolean = em.transaction.isActive +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt new file mode 100644 index 00000000..d9580ff9 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt @@ -0,0 +1,144 @@ +package tech.ydb.keycloak.util + +import com.zaxxer.hikari.HikariDataSource +import jakarta.persistence.EntityManagerFactory +import org.hibernate.boot.registry.StandardServiceRegistryBuilder +import org.hibernate.cfg.AvailableSettings +import org.hibernate.cfg.Configuration +import org.jboss.logging.Logger + +object EntityManagerUtils { + private val logger = Logger.getLogger(EntityManagerUtils::class.java) + + fun createEntityManagerFactory(dataSource: HikariDataSource, showSql: Boolean, formatSql: Boolean): EntityManagerFactory { + logger.info("Creating YDB EntityManagerFactory programmatically") + + val configuration = Configuration() + + configuration.setProperty(AvailableSettings.DIALECT, "tech.ydb.hibernate.dialect.YdbDialect") + configuration.setProperty(AvailableSettings.HBM2DDL_AUTO, "none") + configuration.setProperty(AvailableSettings.SHOW_SQL, showSql) + configuration.setProperty(AvailableSettings.FORMAT_SQL, formatSql) + + configuration.setProperty(AvailableSettings.STATEMENT_BATCH_SIZE, "0") + configuration.setProperty(AvailableSettings.ORDER_INSERTS, "false") + configuration.setProperty(AvailableSettings.ORDER_UPDATES, "false") + + configuration.setProperty(AvailableSettings.DEFAULT_BATCH_FETCH_SIZE, "8") + configuration.setProperty(AvailableSettings.USE_SQL_COMMENTS, "false") + configuration.setProperty("hibernate.query.in_clause_parameter_padding", "true") + configuration.setProperty(AvailableSettings.STATEMENT_FETCH_SIZE, "64") + + addKeycloakEntities(configuration) + + val serviceRegistry = StandardServiceRegistryBuilder() + // TODO use not deprecated instead of DATASOURCE + .applySetting(AvailableSettings.DATASOURCE, dataSource) + .applySettings(configuration.properties) + .build() + + val sessionFactory = configuration.buildSessionFactory(serviceRegistry) + + logger.info("YDB EntityManagerFactory created successfully") + + return sessionFactory.unwrap(EntityManagerFactory::class.java) + } + + fun addKeycloakEntities(configuration: Configuration) { + logger.debug("Adding Keycloak entity classes") + + val entityClasses = listOf( + "org.keycloak.models.jpa.entities.ClientEntity", + "org.keycloak.models.jpa.entities.ClientAttributeEntity", + "org.keycloak.models.jpa.entities.CredentialEntity", + "org.keycloak.models.jpa.entities.RealmEntity", + "org.keycloak.models.jpa.entities.RealmAttributeEntity", + "org.keycloak.models.jpa.entities.RequiredCredentialEntity", + "org.keycloak.models.jpa.entities.ComponentConfigEntity", + "org.keycloak.models.jpa.entities.ComponentEntity", + "org.keycloak.models.jpa.entities.UserFederationProviderEntity", + "org.keycloak.models.jpa.entities.UserFederationMapperEntity", + "org.keycloak.models.jpa.entities.RoleEntity", + "org.keycloak.models.jpa.entities.RoleAttributeEntity", + "org.keycloak.models.jpa.entities.FederatedIdentityEntity", + "org.keycloak.models.jpa.entities.MigrationModelEntity", + "org.keycloak.models.jpa.entities.UserEntity", + "org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity", + "org.keycloak.models.jpa.entities.UserRequiredActionEntity", + "org.keycloak.models.jpa.entities.UserAttributeEntity", + "org.keycloak.models.jpa.entities.UserRoleMappingEntity", + "org.keycloak.models.jpa.entities.IdentityProviderEntity", + "org.keycloak.models.jpa.entities.IdentityProviderMapperEntity", + "org.keycloak.models.jpa.entities.ProtocolMapperEntity", + "org.keycloak.models.jpa.entities.UserConsentEntity", + "org.keycloak.models.jpa.entities.UserConsentClientScopeEntity", + "org.keycloak.models.jpa.entities.AuthenticationFlowEntity", + "org.keycloak.models.jpa.entities.AuthenticationExecutionEntity", + "org.keycloak.models.jpa.entities.AuthenticatorConfigEntity", + "org.keycloak.models.jpa.entities.RequiredActionProviderEntity", + "org.keycloak.models.jpa.session.PersistentUserSessionEntity", + "org.keycloak.models.jpa.session.PersistentClientSessionEntity", + "org.keycloak.models.jpa.entities.RevokedTokenEntity", + "org.keycloak.models.jpa.entities.GroupEntity", + "org.keycloak.models.jpa.entities.GroupAttributeEntity", + "org.keycloak.models.jpa.entities.GroupRoleMappingEntity", + "org.keycloak.models.jpa.entities.UserGroupMembershipEntity", + "org.keycloak.models.jpa.entities.ClientScopeEntity", + "org.keycloak.models.jpa.entities.ClientScopeAttributeEntity", + "org.keycloak.models.jpa.entities.ClientScopeRoleMappingEntity", + "org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity", + "org.keycloak.models.jpa.entities.DefaultClientScopeRealmMappingEntity", + "org.keycloak.models.jpa.entities.ClientInitialAccessEntity", + + // Events + "org.keycloak.events.jpa.EventEntity", + "org.keycloak.events.jpa.AdminEventEntity", + + // Authorization + "org.keycloak.authorization.jpa.entities.ResourceServerEntity", + "org.keycloak.authorization.jpa.entities.ResourceEntity", + "org.keycloak.authorization.jpa.entities.ScopeEntity", + "org.keycloak.authorization.jpa.entities.PolicyEntity", + "org.keycloak.authorization.jpa.entities.PermissionTicketEntity", + "org.keycloak.authorization.jpa.entities.ResourceAttributeEntity", + + // Federated storage + "org.keycloak.storage.jpa.entity.BrokerLinkEntity", + "org.keycloak.storage.jpa.entity.FederatedUser", + "org.keycloak.storage.jpa.entity.FederatedUserAttributeEntity", + "org.keycloak.storage.jpa.entity.FederatedUserConsentEntity", + "org.keycloak.storage.jpa.entity.FederatedUserConsentClientScopeEntity", + "org.keycloak.storage.jpa.entity.FederatedUserCredentialEntity", + "org.keycloak.storage.jpa.entity.FederatedUserGroupMembershipEntity", + "org.keycloak.storage.jpa.entity.FederatedUserRequiredActionEntity", + "org.keycloak.storage.jpa.entity.FederatedUserRoleMappingEntity", + + // Organization + "org.keycloak.models.jpa.entities.OrganizationEntity", + "org.keycloak.models.jpa.entities.OrganizationDomainEntity", + + // Server config + "org.keycloak.storage.configuration.jpa.entity.ServerConfigEntity", + + // Workflows + "org.keycloak.models.workflow.WorkflowStateEntity" + ) + + var addedCount = 0 + var failedCount = 0 + + entityClasses.forEach { className -> + try { + val clazz = Class.forName(className) + configuration.addAnnotatedClass(clazz) + addedCount++ + } catch (e: ClassNotFoundException) { + logger.warn("Entity class not found: $className", e) + failedCount++ + } + } + + logger.info("Added $addedCount entity classes, $failedCount not found") + } + +} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt new file mode 100644 index 00000000..7d966fa3 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt @@ -0,0 +1,20 @@ +package tech.ydb.keycloak.util + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource + +fun hikariDataSource( + jdbcUrl: String?, + poolSize: Int, +): HikariDataSource = HikariDataSource(hikariConfig(jdbcUrl, poolSize)) + +fun hikariConfig( + jdbcUrl: String?, + poolSize: Int, +): HikariConfig = HikariConfig().apply {// todo Review how to create connections correctly. + this.jdbcUrl = jdbcUrl + this.driverClassName = "tech.ydb.jdbc.YdbDriver" + this.maximumPoolSize = poolSize + this.poolName = "YDB-HikariPool" + this.isAutoCommit = false // todo review +} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory new file mode 100644 index 00000000..fb8ab02d --- /dev/null +++ b/keycloak-ydb-extension/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/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100644 index 00000000..9321db82 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1 @@ +tech.ydb.keycloak.connection.YdbConnectionSpi \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory new file mode 100644 index 00000000..cf141884 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql new file mode 100644 index 00000000..0ea8a8ea --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-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/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql new file mode 100644 index 00000000..f0412189 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql @@ -0,0 +1,9 @@ +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), + PRIMARY KEY (NAME, REALM_ID), +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql new file mode 100644 index 00000000..6b6a2d3b --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql @@ -0,0 +1,10 @@ +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, + + primary key (REALM_ID, TYPE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql new file mode 100644 index 00000000..467db2ce --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql new file mode 100644 index 00000000..eb9c592a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `GROUP_ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql new file mode 100644 index 00000000..d8a06a35 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql new file mode 100644 index 00000000..3fb00d41 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql new file mode 100644 index 00000000..1dc313c7 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `REALM_SMTP_CONFIG` +( + `REALM_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + PRIMARY KEY (`REALM_ID`, `NAME`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql new file mode 100644 index 00000000..60694555 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql new file mode 100644 index 00000000..caed1def --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql @@ -0,0 +1,10 @@ +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`), + PRIMARY KEY (`REALM_ID`, `SCOPE_ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql new file mode 100644 index 00000000..f34deebe --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql @@ -0,0 +1,13 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql new file mode 100644 index 00000000..f77ba038 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql @@ -0,0 +1,17 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql new file mode 100644 index 00000000..2e2f290e --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql @@ -0,0 +1,9 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql new file mode 100644 index 00000000..ce278295 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql @@ -0,0 +1,14 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql new file mode 100644 index 00000000..5cfcf531 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql new file mode 100644 index 00000000..53c25a4f --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql new file mode 100644 index 00000000..e73f2f9d --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql @@ -0,0 +1,14 @@ +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), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql new file mode 100644 index 00000000..b5117982 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql @@ -0,0 +1,10 @@ +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), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql new file mode 100644 index 00000000..132067ac --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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 ON (`CLIENT_ID`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql new file mode 100644 index 00000000..67837bac --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml new file mode 100644 index 00000000..1112dc98 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml @@ -0,0 +1,8 @@ + + + + From 5b6b8e766274391c37067393de8212a88578f897 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 1 Feb 2026 15:42:10 +0300 Subject: [PATCH 002/120] chore: Make migrations name in format year-month-day-date-{name-of-migration} --- ...te-realm-table.sql => 2025-12-17-21-07-create-realm-table.sql} | 0 ...ble.sql => 2025-12-17-21-29-create-realm-attributes-table.sql} | 0 ... 2025-12-17-22-00-create-realm-required-credentials-table.sql} | 0 ...l => 2025-12-20-00-20-create-realm_events_listeners-table.sql} | 0 ...sql => 2025-12-20-00-21-create-realm-default-groups-table.sql} | 0 ...> 2025-12-20-00-28-create-realm-enabled-event-types-table.sql} | 0 ....sql => 2025-12-20-00-29-create-realm-localizations-table.sql} | 0 ...le.sql => 2025-12-20-00-30-create-realm-smtp-config-table.sql} | 0 ... => 2025-12-20-00-31-create-realm-supported-locales-table.sql} | 0 ...sql => 2025-12-20-00-34-create-default-client-scope-table.sql} | 0 ....sql => 2025-12-20-00-36-create-authentication-flow-table.sql} | 0 ...=> 2025-12-20-00-38-create-authentication-execution-table.sql} | 0 ...sql => 2025-12-20-00-42-create-authenticator-config-table.sql} | 0 ...=> 2025-12-20-00-44-create-required-action-provider-table.sql} | 0 ...l => 2025-12-20-00-45-create-required-action-config-table.sql} | 0 ... 2025-12-20-00-46-create-authenticator-config-entry-table.sql} | 0 ...nent-table.sql => 2025-12-20-00-47-create-component-table.sql} | 0 ...ble.sql => 2025-12-20-00-48-create-component-config-table.sql} | 0 ...-client-table.sql => 2025-12-20-01-00-create-client-table.sql} | 0 ...y-table.sql => 2025-12-20-01-01-create-event-entity-table.sql} | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{17-12-2025-21-07-create-realm-table.sql => 2025-12-17-21-07-create-realm-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{17-12-2025-21-29-create-realm-attributes-table.sql => 2025-12-17-21-29-create-realm-attributes-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{17-12-2025-22-00-create-realm-required-credentials-table.sql => 2025-12-17-22-00-create-realm-required-credentials-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-20-create-realm_events_listeners-table.sql => 2025-12-20-00-20-create-realm_events_listeners-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-21-create-realm-default-groups-table.sql => 2025-12-20-00-21-create-realm-default-groups-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-28-create-realm-enabled-event-types-table.sql => 2025-12-20-00-28-create-realm-enabled-event-types-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-29-create-realm-localizations-table.sql => 2025-12-20-00-29-create-realm-localizations-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-30-create-realm-smtp-config-table.sql => 2025-12-20-00-30-create-realm-smtp-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-31-create-realm-supported-locales-table.sql => 2025-12-20-00-31-create-realm-supported-locales-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-34-create-default-client-scope-table.sql => 2025-12-20-00-34-create-default-client-scope-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-36-create-authentication-flow-table.sql => 2025-12-20-00-36-create-authentication-flow-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-38-create-authentication-execution-table.sql => 2025-12-20-00-38-create-authentication-execution-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-42-create-authenticator-config-table.sql => 2025-12-20-00-42-create-authenticator-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-44-create-required-action-provider-table.sql => 2025-12-20-00-44-create-required-action-provider-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-45-create-required-action-config-table.sql => 2025-12-20-00-45-create-required-action-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-46-create-authenticator-config-entry-table.sql => 2025-12-20-00-46-create-authenticator-config-entry-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-47-create-component-table.sql => 2025-12-20-00-47-create-component-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-48-create-component-config-table.sql => 2025-12-20-00-48-create-component-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-01-00-create-client-table.sql => 2025-12-20-01-00-create-client-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-01-01-create-event-entity-table.sql => 2025-12-20-01-01-create-event-entity-table.sql} (100%) diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql From d5d735e1dffdf9cda50c4d339b224415adbb368d Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Feb 2026 02:06:02 +0300 Subject: [PATCH 003/120] feat: Implement JpaConnectionProviderFactory to not implement own YdbJpaTransaction --- .../DefaultYdbConnectionProviderFactory.kt | 79 +++++++++++++------ .../connection/YdbConnectionProvider.kt | 5 +- .../keycloak/realm/YdbRealmProviderFactory.kt | 6 +- .../keycloak/transaction/YdbJpaTransaction.kt | 33 -------- ...nections.jpa.JpaConnectionProviderFactory} | 2 +- 5 files changed, 61 insertions(+), 64 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt rename keycloak-ydb-extension/src/main/resources/META-INF/services/{tech.ydb.keycloak.connection.YdbConnectionProviderFactory => org.keycloak.connections.jpa.JpaConnectionProviderFactory} (98%) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt index 895bb871..6bad9714 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt @@ -1,21 +1,27 @@ package tech.ydb.keycloak.connection -import tech.ydb.keycloak.migration.YdbMigrationManager.migrate import com.zaxxer.hikari.HikariDataSource import jakarta.persistence.EntityManager import jakarta.persistence.EntityManagerFactory import org.jboss.logging.Logger import org.keycloak.Config +import org.keycloak.connections.jpa.DefaultJpaConnectionProvider +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.connections.jpa.JpaConnectionProviderFactory +import org.keycloak.connections.jpa.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.provider.EnvironmentDependentProviderFactory +import org.keycloak.provider.ServerInfoAwareProviderFactory import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -import tech.ydb.keycloak.transaction.YdbJpaTransaction +import tech.ydb.keycloak.migration.YdbMigrationManager.migrate import tech.ydb.keycloak.util.EntityManagerUtils.createEntityManagerFactory import tech.ydb.keycloak.util.hikariDataSource +import java.sql.Connection -class DefaultYdbConnectionProviderFactory : YdbConnectionProviderFactory, +class DefaultYdbConnectionProviderFactory : JpaConnectionProviderFactory, + ServerInfoAwareProviderFactory, EnvironmentDependentProviderFactory { private val logger: Logger = Logger.getLogger(DefaultYdbConnectionProviderFactory::class.java) @@ -23,11 +29,21 @@ class DefaultYdbConnectionProviderFactory : YdbConnectionProviderFactory = mapOf( + "YDB" to "enabled", + "Pool" to dataSource.hikariPoolMXBean.activeConnections.toString(), + ) - private fun createEntityManager(session: KeycloakSession): EntityManager { - val em = entityManagerFactory.createEntityManager() + private fun createEntityManager(session: KeycloakSession, emf: EntityManagerFactory): EntityManager { + val em = emf.createEntityManager() - val tx = YdbJpaTransaction(em) + val tx = JpaKeycloakTransaction(em) session.transactionManager.enlist(tx) return EntityManagerProxy.create(session, em, true) } private companion object { - private const val PROVIDER_ID: String = "default" + private const val PROVIDER_ID = "default" + private const val ORDER_YDB_FIRST = 2 + private const val schemaName = "public" } -} \ No newline at end of file +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt index 14d5031c..c4de0290 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt @@ -1,8 +1,5 @@ package tech.ydb.keycloak.connection -import jakarta.persistence.EntityManager import org.keycloak.provider.Provider -interface YdbConnectionProvider : Provider { - val entityManager: EntityManager -} +interface YdbConnectionProvider : Provider diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 935a0981..37bf866d 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -6,18 +6,18 @@ import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.models.RealmProviderFactory import org.keycloak.provider.EnvironmentDependentProviderFactory +import org.keycloak.connections.jpa.JpaConnectionProvider import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -import tech.ydb.keycloak.connection.YdbConnectionProvider class YdbRealmProviderFactory() : RealmProviderFactory, EnvironmentDependentProviderFactory { private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) override fun create(session: KeycloakSession): YdbRealmProvider { - val provider = session.getProvider(YdbConnectionProvider::class.java)?.let { + val provider = session.getProvider(JpaConnectionProvider::class.java)?.let { YdbRealmProvider(session, it.entityManager) - } ?: error("YdbConnectionProvider is not configured") + } ?: error("JpaConnectionProvider is not configured in YDB") logger.info("YdbRealmProvider successfully created") diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt deleted file mode 100644 index da876df5..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt +++ /dev/null @@ -1,33 +0,0 @@ -package tech.ydb.keycloak.transaction - -import jakarta.persistence.EntityManager -import org.keycloak.models.KeycloakTransaction - -class YdbJpaTransaction( - private val em: EntityManager -) : KeycloakTransaction { - - override fun begin() { - em.transaction.begin() - } - - override fun commit() { - em.transaction.commit() - } - - override fun rollback() { - if (em.transaction.isActive) { - em.transaction.rollback() - } - } - - override fun setRollbackOnly() { - if (em.transaction.isActive) { - em.transaction.setRollbackOnly() - } - } - - override fun getRollbackOnly(): Boolean = em.transaction.rollbackOnly - - override fun isActive(): Boolean = em.transaction.isActive -} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory similarity index 98% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory rename to keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory index cf141884..334d3371 100644 --- a/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory @@ -1 +1 @@ -tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory \ No newline at end of file +tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory From 5bbcd34b16b207a1a1ec3ab03ed5e5fe16addd10 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:33:05 +0300 Subject: [PATCH 004/120] feat: Use keycloak like code to create connection with YDB. Use YdbDatabase from liquibase-dialect in YdbLiquibaseConnectionProvider --- keycloak-ydb-extension/README.md | 32 +- .../docker/docker-compose.yml | 18 +- keycloak-ydb-extension/pom.xml | 3 + .../run-keycloack-with-ydb.sh | 12 +- .../ydb/keycloak/config/ProviderPriority.kt | 3 +- .../DefaultYdbConnectionProviderFactory.kt | 113 ------ .../YdbConnectionProviderFactoryImpl.kt | 323 ++++++++++++++++++ .../YdbLiquibaseConnectionProvider.kt | 81 +++++ .../keycloak/migration/YdbMigrationManager.kt | 32 -- .../keycloak/realm/YdbRealmProviderFactory.kt | 2 +- .../ydb/keycloak/util/EntityManagerUtils.kt | 144 -------- .../tech/ydb/keycloak/util/MigrationUtils.kt | 20 -- ...nnections.jpa.JpaConnectionProviderFactory | 2 +- ...se.conn.LiquibaseConnectionProviderFactory | 1 + 14 files changed, 461 insertions(+), 325 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 4e981983..70b83059 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -1,12 +1,34 @@ # Keycloak YDB extension ## Overview -Keycloak extension to store data using YDB +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. -## Getting Started -To store keycloak data in ydb. Mount jar build of this project to keycloak. +## Configuration -### Local development +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: -`run-keycloack-with-ydb.sh` script builds project and mount jar of this project to keycloak. \ No newline at end of file +| Variable | Value | Purpose | +|----------|--------|---------| +| `KC_DB` | `dev-file` | Built-in datasource uses dev-file; it never sees the YDB URL. | +| `KC_YDB_URL` | `jdbc:ydb:grpc://host:2136/database` | JDBC URL used by this extension. | +| `KC_COMMUNITY_DATASTORE_YDB_ENABLED` | `true` | Enables this extension’s JPA provider. | +| `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). | + +Alternative: you can set `KC_DB_URL` instead of `KC_YDB_URL`; the extension will use it when the YDB profile is enabled. Then still set `KC_DB=dev-file` so the default pool is not created with that URL. + +## 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-keycloack-with-ydb.sh +``` + +This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. \ No newline at end of file diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 1a38fd9f..6f1b6d27 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -40,17 +40,21 @@ services: volumes: - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar environment: - # YDB Configuration + # Use dev-file for the built-in datasource so it never sees the YDB URL (no "driver does not support URL" error). + # YDB JDBC URL (config key ydbJdbcUrl / ydb-url; env KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL). + - KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local + - KC_SPI_CONNECTIONS_JPA_SHOW_SQL=true - KC_COMMUNITY_DATASTORE_YDB_ENABLED=true - - KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local - - KC_SPI_YDB_CONNECTION_DEFAULT_CREATE_SCHEMA=true - # Keycloak Admin + # Disable default JPA and Liquibase providers so H2 is not used (only YDB is used). + - KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED=false + - KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED=false + # Admin - KEYCLOAK_ADMIN=admin - KEYCLOAK_ADMIN_PASSWORD=admin entrypoint: /opt/keycloak/bin/kc.sh command: > - -v start-dev - --cache=local + -v start-dev + --cache=local --features-disabled=authorization,admin-fine-grained-authz,organization ports: - 9090:8080 @@ -58,4 +62,4 @@ services: - keycloak depends_on: ydb: - condition: service_started \ No newline at end of file + condition: service_started diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index 3de2bca7..560fa287 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -202,6 +202,9 @@ tech.ydb.dialects hibernate-ydb-dialect ${hibernate.ydb.dialect.version} + + + diff --git a/keycloak-ydb-extension/run-keycloack-with-ydb.sh b/keycloak-ydb-extension/run-keycloack-with-ydb.sh index fe3724ab..2ec18c34 100644 --- a/keycloak-ydb-extension/run-keycloack-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloack-with-ydb.sh @@ -1,7 +1,17 @@ +rm -f docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + mvn clean package mkdir -p docker/providers -cp target/keycloak-ydb-extension-1.0-SNAPSHOT.jar docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar +JAR_FILE="target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" + +if [ ! -f "$JAR_FILE" ]; then + echo "Ошибка: Файл $JAR_FILE не найден!" + echo "Сборка проекта, возможно, завершилась неудачно." + exit 1 +fi + +cp "$JAR_FILE" docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar docker-compose -f docker/docker-compose.yml up -d \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt index 342b7066..7abe5c15 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt @@ -1,5 +1,6 @@ package tech.ydb.keycloak.config object ProviderPriority { - const val PROVIDER_PRIORITY = 1 + // more than in quarkus provider factories + const val PROVIDER_PRIORITY = 200 } diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt deleted file mode 100644 index 6bad9714..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt +++ /dev/null @@ -1,113 +0,0 @@ -package tech.ydb.keycloak.connection - -import com.zaxxer.hikari.HikariDataSource -import jakarta.persistence.EntityManager -import jakarta.persistence.EntityManagerFactory -import org.jboss.logging.Logger -import org.keycloak.Config -import org.keycloak.connections.jpa.DefaultJpaConnectionProvider -import org.keycloak.connections.jpa.JpaConnectionProvider -import org.keycloak.connections.jpa.JpaConnectionProviderFactory -import org.keycloak.connections.jpa.JpaKeycloakTransaction -import org.keycloak.connections.jpa.support.EntityManagerProxy -import org.keycloak.models.KeycloakSession -import org.keycloak.models.KeycloakSessionFactory -import org.keycloak.provider.EnvironmentDependentProviderFactory -import org.keycloak.provider.ServerInfoAwareProviderFactory -import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -import tech.ydb.keycloak.migration.YdbMigrationManager.migrate -import tech.ydb.keycloak.util.EntityManagerUtils.createEntityManagerFactory -import tech.ydb.keycloak.util.hikariDataSource -import java.sql.Connection - -class DefaultYdbConnectionProviderFactory : JpaConnectionProviderFactory, - ServerInfoAwareProviderFactory, - EnvironmentDependentProviderFactory { - - private val logger: Logger = Logger.getLogger(DefaultYdbConnectionProviderFactory::class.java) - - private lateinit var dataSource: HikariDataSource - private lateinit var entityManagerFactory: EntityManagerFactory - - override fun create(session: KeycloakSession): JpaConnectionProvider { - val em = createEntityManager(session, entityManagerFactory) - return DefaultJpaConnectionProvider(em) - } - - override fun init(scope: Config.Scope) { - if (!isSupported(scope)) { - logger.debug("YDB JPA provider disabled (profile not enabled), skipping init") - return - } - val jdbcUrl = resolveJdbcUrl(scope) - if (jdbcUrl.isNullOrBlank()) { - logger.warn("YDB JPA provider enabled but no JDBC URL configured. Set KC_SPI_CONNECTIONS_JPA_DEFAULT_JDBC_URL (or KC_DB_JDBC_URL) or legacy KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL. Skipping init.") - return - } - val poolSize = scope.getInt("poolSize", 10) - val connectionTimeout = scope.getLong("connectionTimeout", 5000L) // todo review - val showSql = scope.getBoolean("showSql", false) - val formatSql = scope.getBoolean("formatSql", true) - - dataSource = hikariDataSource(jdbcUrl, poolSize) - - // TODO: maybe reuse JpaUtils.createEntityManagerFactory - // as it is inside DefaultJpaConnectionProviderFactory.lazyInit - entityManagerFactory = createEntityManagerFactory(dataSource, showSql, formatSql) - - logger.info("YDB connection pool and EntityManagerFactory configured successfully") - migrate(dataSource) - } - - // TODO: simplify this - private fun resolveJdbcUrl(scope: Config.Scope): String? = - scope["jdbcUrl"]?.takeIf { it.isNotBlank() } - ?: scope["url"]?.takeIf { it.isNotBlank() } - ?: scope["jdbc-url"]?.takeIf { it.isNotBlank() } - ?: System.getenv("KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL")?.takeIf { it.isNotBlank() } - ?: System.getenv("KC_DB_JDBC_URL")?.takeIf { it.isNotBlank() } - ?: System.getenv("KC_DB_URL")?.takeIf { it.isNotBlank() } - - override fun postInit(factory: KeycloakSessionFactory) { - // no operations - } - - override fun close() { - if (::entityManagerFactory.isInitialized) { - entityManagerFactory.close() - } - if (::dataSource.isInitialized) { - dataSource.close() - } - } - - override fun getId(): String = PROVIDER_ID - - override fun order(): Int = ORDER_YDB_FIRST - - override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - - override fun getConnection(): Connection = dataSource.connection - - override fun getSchema(): String = schemaName - - override fun getOperationalInfo(): Map = mapOf( - "YDB" to "enabled", - "Pool" to dataSource.hikariPoolMXBean.activeConnections.toString(), - ) - - private fun createEntityManager(session: KeycloakSession, emf: EntityManagerFactory): EntityManager { - val em = emf.createEntityManager() - - val tx = JpaKeycloakTransaction(em) - session.transactionManager.enlist(tx) - - return EntityManagerProxy.create(session, em, true) - } - - private companion object { - private const val PROVIDER_ID = "default" - private const val ORDER_YDB_FIRST = 2 - private const val schemaName = "public" - } -} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt new file mode 100644 index 00000000..c02fc94d --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -0,0 +1,323 @@ +package tech.ydb.keycloak.connection + +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.JpaKeycloakTransaction +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.EnvironmentDependentProviderFactory +import org.keycloak.provider.ServerInfoAwareProviderFactory +import tech.ydb.hibernate.dialect.YdbDialect +import tech.ydb.jdbc.YdbDriver +import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED +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, + EnvironmentDependentProviderFactory { + + private val logger: Logger = Logger.getLogger(YdbConnectionProviderFactoryImpl::class.java) + + private lateinit var config: Config.Scope + + private var jtaEnabled by Delegates.notNull() + + @Volatile + private lateinit var entityManagerFactory: EntityManagerFactory + + 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) + } + + if (!jtaEnabled) { + session.transactionManager.enlist(JpaKeycloakTransaction(em)) + } + return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) + } + + override fun init(scope: Config.Scope) { + if (!isSupported(scope)) { + logger.debug("YDB JPA disabled (profile not enabled), skipping init") + return + } + config = scope + } + + 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) } + } + + // TODO: FIND OUT HOW IT SMTH LIKE spi.connections-jpa.default.ydb-jdbc-url + private fun resolveJdbcUrl(): String = requireNotNull(config["ydbJdbcUrl"]) { + " YDB JDBC URL is required. Set env variable KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL= " + } + + 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() + } + } + + override fun getId(): String = "default" + + override fun order(): Int = PROVIDER_PRIORITY + + override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED + + override fun getConnection(): Connection { + try { + val url = resolveJdbcUrl() + val driver = YdbDriver::class.java.name + Class.forName(driver) + return DriverManager.getConnection(url) + } 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) + logger.info("YDB EntityManagerFactory created via JpaUtils") + return entityManagerFactory + } + } + + private fun buildPropertiesFromScope(): MutableMap { + val properties = mutableMapOf() + + properties[AvailableSettings.JAKARTA_JDBC_URL] = resolveJdbcUrl() + properties[AvailableSettings.JAKARTA_JDBC_DRIVER] = YdbDriver::class.java.name + + 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" + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt new file mode 100644 index 00000000..a2571998 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -0,0 +1,81 @@ +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 org.keycloak.provider.EnvironmentDependentProviderFactory +import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.YdbProfile +import tech.ydb.liquibase.database.YdbDatabase +import java.sql.Connection + +class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider(), EnvironmentDependentProviderFactory { + + 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 isSupported(scope: Config.Scope): Boolean = YdbProfile.IS_YDB_PROFILE_ENABLED + + 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 PROVIDER_ID: String = "ydb-liquibase" + 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/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt deleted file mode 100644 index 8b7fc497..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package tech.ydb.keycloak.migration - -import liquibase.Liquibase -import liquibase.database.jvm.JdbcConnection -import liquibase.exception.LiquibaseException -import liquibase.resource.ClassLoaderResourceAccessor -import org.jboss.logging.Logger -import java.sql.SQLException -import javax.sql.DataSource - -object YdbMigrationManager { - private const val CHANGELOG_FILE: String = "ydb/db.changelog-master.xml" - - private val logger = Logger.getLogger(YdbMigrationManager::class.java) - - fun migrate(dataSource: DataSource) { - logger.info("Starting YDB migrations using Liquibase...") - - try { - dataSource.connection.use { connection -> - Liquibase(CHANGELOG_FILE, ClassLoaderResourceAccessor(), JdbcConnection(connection)).use { liquibase -> - liquibase.update() - logger.info("YDB migrations completed successfully") - } - } - } catch (e: LiquibaseException) { - logger.error("Failed to execute YDB migrations", e) - - throw SQLException("Failed to execute YDB migrations", e) - } - } -} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 37bf866d..807f96ef 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -40,7 +40,7 @@ class YdbRealmProviderFactory() : RealmProviderFactory, Enviro override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - override fun order(): Int = PROVIDER_PRIORITY + 1 + override fun order(): Int = PROVIDER_PRIORITY private companion object { private const val ID = "ydb-realm-provider-factory" diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt deleted file mode 100644 index d9580ff9..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt +++ /dev/null @@ -1,144 +0,0 @@ -package tech.ydb.keycloak.util - -import com.zaxxer.hikari.HikariDataSource -import jakarta.persistence.EntityManagerFactory -import org.hibernate.boot.registry.StandardServiceRegistryBuilder -import org.hibernate.cfg.AvailableSettings -import org.hibernate.cfg.Configuration -import org.jboss.logging.Logger - -object EntityManagerUtils { - private val logger = Logger.getLogger(EntityManagerUtils::class.java) - - fun createEntityManagerFactory(dataSource: HikariDataSource, showSql: Boolean, formatSql: Boolean): EntityManagerFactory { - logger.info("Creating YDB EntityManagerFactory programmatically") - - val configuration = Configuration() - - configuration.setProperty(AvailableSettings.DIALECT, "tech.ydb.hibernate.dialect.YdbDialect") - configuration.setProperty(AvailableSettings.HBM2DDL_AUTO, "none") - configuration.setProperty(AvailableSettings.SHOW_SQL, showSql) - configuration.setProperty(AvailableSettings.FORMAT_SQL, formatSql) - - configuration.setProperty(AvailableSettings.STATEMENT_BATCH_SIZE, "0") - configuration.setProperty(AvailableSettings.ORDER_INSERTS, "false") - configuration.setProperty(AvailableSettings.ORDER_UPDATES, "false") - - configuration.setProperty(AvailableSettings.DEFAULT_BATCH_FETCH_SIZE, "8") - configuration.setProperty(AvailableSettings.USE_SQL_COMMENTS, "false") - configuration.setProperty("hibernate.query.in_clause_parameter_padding", "true") - configuration.setProperty(AvailableSettings.STATEMENT_FETCH_SIZE, "64") - - addKeycloakEntities(configuration) - - val serviceRegistry = StandardServiceRegistryBuilder() - // TODO use not deprecated instead of DATASOURCE - .applySetting(AvailableSettings.DATASOURCE, dataSource) - .applySettings(configuration.properties) - .build() - - val sessionFactory = configuration.buildSessionFactory(serviceRegistry) - - logger.info("YDB EntityManagerFactory created successfully") - - return sessionFactory.unwrap(EntityManagerFactory::class.java) - } - - fun addKeycloakEntities(configuration: Configuration) { - logger.debug("Adding Keycloak entity classes") - - val entityClasses = listOf( - "org.keycloak.models.jpa.entities.ClientEntity", - "org.keycloak.models.jpa.entities.ClientAttributeEntity", - "org.keycloak.models.jpa.entities.CredentialEntity", - "org.keycloak.models.jpa.entities.RealmEntity", - "org.keycloak.models.jpa.entities.RealmAttributeEntity", - "org.keycloak.models.jpa.entities.RequiredCredentialEntity", - "org.keycloak.models.jpa.entities.ComponentConfigEntity", - "org.keycloak.models.jpa.entities.ComponentEntity", - "org.keycloak.models.jpa.entities.UserFederationProviderEntity", - "org.keycloak.models.jpa.entities.UserFederationMapperEntity", - "org.keycloak.models.jpa.entities.RoleEntity", - "org.keycloak.models.jpa.entities.RoleAttributeEntity", - "org.keycloak.models.jpa.entities.FederatedIdentityEntity", - "org.keycloak.models.jpa.entities.MigrationModelEntity", - "org.keycloak.models.jpa.entities.UserEntity", - "org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity", - "org.keycloak.models.jpa.entities.UserRequiredActionEntity", - "org.keycloak.models.jpa.entities.UserAttributeEntity", - "org.keycloak.models.jpa.entities.UserRoleMappingEntity", - "org.keycloak.models.jpa.entities.IdentityProviderEntity", - "org.keycloak.models.jpa.entities.IdentityProviderMapperEntity", - "org.keycloak.models.jpa.entities.ProtocolMapperEntity", - "org.keycloak.models.jpa.entities.UserConsentEntity", - "org.keycloak.models.jpa.entities.UserConsentClientScopeEntity", - "org.keycloak.models.jpa.entities.AuthenticationFlowEntity", - "org.keycloak.models.jpa.entities.AuthenticationExecutionEntity", - "org.keycloak.models.jpa.entities.AuthenticatorConfigEntity", - "org.keycloak.models.jpa.entities.RequiredActionProviderEntity", - "org.keycloak.models.jpa.session.PersistentUserSessionEntity", - "org.keycloak.models.jpa.session.PersistentClientSessionEntity", - "org.keycloak.models.jpa.entities.RevokedTokenEntity", - "org.keycloak.models.jpa.entities.GroupEntity", - "org.keycloak.models.jpa.entities.GroupAttributeEntity", - "org.keycloak.models.jpa.entities.GroupRoleMappingEntity", - "org.keycloak.models.jpa.entities.UserGroupMembershipEntity", - "org.keycloak.models.jpa.entities.ClientScopeEntity", - "org.keycloak.models.jpa.entities.ClientScopeAttributeEntity", - "org.keycloak.models.jpa.entities.ClientScopeRoleMappingEntity", - "org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity", - "org.keycloak.models.jpa.entities.DefaultClientScopeRealmMappingEntity", - "org.keycloak.models.jpa.entities.ClientInitialAccessEntity", - - // Events - "org.keycloak.events.jpa.EventEntity", - "org.keycloak.events.jpa.AdminEventEntity", - - // Authorization - "org.keycloak.authorization.jpa.entities.ResourceServerEntity", - "org.keycloak.authorization.jpa.entities.ResourceEntity", - "org.keycloak.authorization.jpa.entities.ScopeEntity", - "org.keycloak.authorization.jpa.entities.PolicyEntity", - "org.keycloak.authorization.jpa.entities.PermissionTicketEntity", - "org.keycloak.authorization.jpa.entities.ResourceAttributeEntity", - - // Federated storage - "org.keycloak.storage.jpa.entity.BrokerLinkEntity", - "org.keycloak.storage.jpa.entity.FederatedUser", - "org.keycloak.storage.jpa.entity.FederatedUserAttributeEntity", - "org.keycloak.storage.jpa.entity.FederatedUserConsentEntity", - "org.keycloak.storage.jpa.entity.FederatedUserConsentClientScopeEntity", - "org.keycloak.storage.jpa.entity.FederatedUserCredentialEntity", - "org.keycloak.storage.jpa.entity.FederatedUserGroupMembershipEntity", - "org.keycloak.storage.jpa.entity.FederatedUserRequiredActionEntity", - "org.keycloak.storage.jpa.entity.FederatedUserRoleMappingEntity", - - // Organization - "org.keycloak.models.jpa.entities.OrganizationEntity", - "org.keycloak.models.jpa.entities.OrganizationDomainEntity", - - // Server config - "org.keycloak.storage.configuration.jpa.entity.ServerConfigEntity", - - // Workflows - "org.keycloak.models.workflow.WorkflowStateEntity" - ) - - var addedCount = 0 - var failedCount = 0 - - entityClasses.forEach { className -> - try { - val clazz = Class.forName(className) - configuration.addAnnotatedClass(clazz) - addedCount++ - } catch (e: ClassNotFoundException) { - logger.warn("Entity class not found: $className", e) - failedCount++ - } - } - - logger.info("Added $addedCount entity classes, $failedCount not found") - } - -} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt deleted file mode 100644 index 7d966fa3..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt +++ /dev/null @@ -1,20 +0,0 @@ -package tech.ydb.keycloak.util - -import com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource - -fun hikariDataSource( - jdbcUrl: String?, - poolSize: Int, -): HikariDataSource = HikariDataSource(hikariConfig(jdbcUrl, poolSize)) - -fun hikariConfig( - jdbcUrl: String?, - poolSize: Int, -): HikariConfig = HikariConfig().apply {// todo Review how to create connections correctly. - this.jdbcUrl = jdbcUrl - this.driverClassName = "tech.ydb.jdbc.YdbDriver" - this.maximumPoolSize = poolSize - this.poolName = "YDB-HikariPool" - this.isAutoCommit = false // todo review -} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory index 334d3371..9348d601 100644 --- a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory @@ -1 +1 @@ -tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory +tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory b/keycloak-ydb-extension/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/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.liquibase.YdbLiquibaseConnectionProvider From 2f65afdc03f8ca57df18fdd71e047d4bcf48ea51 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:35:15 +0300 Subject: [PATCH 005/120] feat: Add migrations to keycloak started successfully with ydb. Format old migrations --- .../2025-12-17-21-07-create-realm-table.sql | 2 +- ...17-21-29-create-realm-attributes-table.sql | 2 +- ...reate-realm-required-credentials-table.sql | 2 +- .../2026-02-01-15-04-keycloak-role-table.sql | 18 ++ .../2026-02-01-16-04-composite-role-table.sql | 9 + .../2026-02-03-2-16-role-attrubute-table.sql | 11 + ...-06-06-00-create-migration-model-table.sql | 8 + ...6-21-24-create-client-attributes-table.sql | 10 + ...02-06-21-34-create-redirect-uris-table.sql | 9 + ...06-21-35-create-protocol-mappers-table.sql | 15 ++ ...38-create-protocol-mapper-config-table.sql | 9 + ...02-07-02-06-create-scope-mapping-table.sql | 9 + ...6-02-07-03-53-create-web-origins-table.sql | 9 + ...-02-07-15-38-create-client-scope-table.sql | 14 ++ ...0-create-client-scope-attributes-table.sql | 10 + ...15-51-create-client-scope-client-table.sql | 10 + ...create-client-scope-role-mapping-table.sql | 11 + ...6-02-07-16-00-create-user-entity-table.sql | 22 ++ ...7-16-02-create-user-role-mapping-table.sql | 10 + ...7-16-05-create-identity-provider-table.sql | 25 ++ ...-create-identity-provider-config-table.sql | 10 + ...-create-identity-provider-mapper-table.sql | 12 + ...26-02-07-16-09-create-credential-table.sql | 17 ++ ...2-07-16-11-create-user-attribute-table.sql | 19 ++ ...02-07-16-15-create-revoked-token-table.sql | 8 + ...create-client-auth-flow-bindings-table.sql | 12 + ...create-client-node-registrations-table.sql | 11 + ...6-24-create-user-required-action-table.sql | 9 + ...26-create-offline-client-session-table.sql | 16 ++ ...6-27-create-offline-user-session-table.sql | 18 ++ ...-37-create-user-group-membership-table.sql | 12 + ...2-07-16-40-create-keycloak-group-table.sql | 16 ++ .../2026-02-07-16-53-create-org-table.sql | 17 ++ ...26-02-07-16-55-create-org-domain-table.sql | 10 + ...-02-07-17-04-create-user-consent-table.sql | 18 ++ ...create-user-consent-client-scope-table.sql | 11 + ...-17-08-create-federated-identity-table.sql | 16 ++ ...07-17-09-create-fed-user-consent-table.sql | 19 ++ ...create-fed-user-consent-cl-scope-table.sql | 11 + ...17-11-create-fed-user-credential-table.sql | 20 ++ ...create-fed-user-group-membership-table.sql | 15 ++ ...-create-fed-user-required-action-table.sql | 13 + ...-13-create-fed-user-role-mapping-table.sql | 15 ++ ...2-07-17-14-create-federated-user-table.sql | 11 + ...6-02-07-17-15-create-broker-link-table.sql | 17 ++ ...-17-16-create-fed-user-attribute-table.sql | 20 ++ ...-07-17-17-create-group-attribute-table.sql | 12 + ...-17-18-create-group-role-mapping-table.sql | 11 + ...-08-19-53-create-resource-server-table.sql | 9 + ...54-create-resource-server-policy-table.sql | 19 ++ ...-create-resource-server-resource-table.sql | 18 ++ ...-19-57-create-resource-attribute-table.sql | 12 + ...-08-19-58-create-resource-policy-table.sql | 11 + ...-59-create-resource-server-scope-table.sql | 14 ++ ...2-08-20-00-create-resource-scope-table.sql | 11 + ...eate-resource-server-perm-ticket-table.sql | 26 ++ ...02-08-20-02-create-resource-uris-table.sql | 10 + .../resources/ydb/db.changelog-master.xml | 223 +++++++++++++++++- 58 files changed, 960 insertions(+), 4 deletions(-) create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql index 0ea8a8ea..2d04eeb8 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql @@ -57,4 +57,4 @@ CREATE TABLE IF NOT EXISTS REALM 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/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql index f0412189..e4ce543f 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql @@ -5,5 +5,5 @@ CREATE TABLE IF NOT EXISTS REALM_ATTRIBUTE VALUE Utf8, INDEX realm_attributes_idx_realm_id GLOBAL ON (REALM_ID), - PRIMARY KEY (NAME, REALM_ID), + PRIMARY KEY (NAME, REALM_ID) ); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql index 6b6a2d3b..cbdc40f3 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql @@ -6,5 +6,5 @@ create table IF NOT EXISTS REALM_REQUIRED_CREDENTIAL INPUT Bool not null default false, SECRET Bool not null default false, - primary key (REALM_ID, TYPE) + PRIMARY KEY (REALM_ID, TYPE) ); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql new file mode 100644 index 00000000..da00e0c0 --- /dev/null +++ b/keycloak-ydb-extension/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`), + +-- foreign key to REALM + INDEX fk_6vyqfe4cn4wlq8r6kt5vdsj5c GLOBAL ON (`REALM`), + INDEX `UK_J3RWUVD56ONTGSUHOGM184WW2-2` GLOBAL UNIQUE ON (`NAME`, `CLIENT_REALM_CONSTRAINT`), + PRIMARY KEY (`ID`) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql new file mode 100644 index 00000000..2b10487a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql @@ -0,0 +1,9 @@ +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), + PRIMARY KEY (COMPOSITE, CHILD_ROLE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql new file mode 100644 index 00000000..1b8a6293 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS MIGRATION_MODEL +( + `ID` Utf8 NOT NULL, + `VERSION` Utf8, + `UPDATE_TIME` Int64 NOT NULL DEFAULT 0, + + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql new file mode 100644 index 00000000..d164d690 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql @@ -0,0 +1,10 @@ +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`, SUBSTRING(`VALUE`, 1, 255)), +-- not implemented in ydb... + PRIMARY KEY (CLIENT_ID, NAME) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql new file mode 100644 index 00000000..dfd04c04 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql @@ -0,0 +1,11 @@ +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), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (SCOPE_ID, ROLE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql new file mode 100644 index 00000000..93711a53 --- /dev/null +++ b/keycloak-ydb-extension/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 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/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql new file mode 100644 index 00000000..cf21289b --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql @@ -0,0 +1,10 @@ +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), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ROLE_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql new file mode 100644 index 00000000..4d48924c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_CONFIG +( + `IDENTITY_PROVIDER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_idp_config_provider GLOBAL ON (IDENTITY_PROVIDER_ID), +-- 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/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql new file mode 100644 index 00000000..20ab6df2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS CLIENT_AUTH_FLOW_BINDINGS +( + `CLIENT_ID` Utf8 NOT NULL, + `FLOW_ID` Utf8, + `BINDING_NAME` Utf8 NOT NULL, + + INDEX idx_cl_auth_flow_client GLOBAL ON (CLIENT_ID), + INDEX idx_cl_auth_flow_flow GLOBAL ON (FLOW_ID), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), +-- FOREIGN KEY (FLOW_ID) REFERENCES AUTHENTICATION_FLOW (ID), + PRIMARY KEY (CLIENT_ID, BINDING_NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql new file mode 100644 index 00000000..22134d31 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS CLIENT_NODE_REGISTRATIONS +( + `CLIENT_ID` Utf8 NOT NULL, + `VALUE` Int32, + `NAME` Utf8 NOT NULL, + + INDEX idx_cl_node_reg_client GLOBAL ON (CLIENT_ID), + INDEX idx_cl_node_reg_name GLOBAL ON (NAME), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql new file mode 100644 index 00000000..8a09167d --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql @@ -0,0 +1,16 @@ +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_offl_client_sess_client GLOBAL ON (CLIENT_ID), + INDEX idx_offl_client_sess_user GLOBAL ON (USER_SESSION_ID), + INDEX idx_offl_client_sess_ext_client GLOBAL ON (EXTERNAL_CLIENT_ID), + 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/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql new file mode 100644 index 00000000..2c782e97 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql @@ -0,0 +1,18 @@ +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), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (USER_SESSION_ID, OFFLINE_FLAG) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql new file mode 100644 index 00000000..6eff3d61 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql @@ -0,0 +1,12 @@ +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), + INDEX idx_user_group_group GLOBAL ON (GROUP_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (GROUP_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql new file mode 100644 index 00000000..dc48ff3e --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql @@ -0,0 +1,16 @@ +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, + + INDEX idx_keycloak_group_parent GLOBAL ON (PARENT_GROUP), + INDEX idx_keycloak_group_realm GLOBAL ON (REALM_ID), +-- CONSTRAINT `SIBLING_NAMES` GLOBAL UNIQUE ON (REALM_ID, PARENT_GROUP, NAME), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (PARENT_GROUP) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql new file mode 100644 index 00000000..63cc6da8 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql @@ -0,0 +1,17 @@ +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), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql new file mode 100644 index 00000000..5bf0d047 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql @@ -0,0 +1,18 @@ +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), + INDEX idx_user_consent_client GLOBAL ON (CLIENT_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), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql new file mode 100644 index 00000000..2000c865 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql @@ -0,0 +1,11 @@ +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), +-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql new file mode 100644 index 00000000..de91798a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql @@ -0,0 +1,16 @@ +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), + INDEX idx_fedidentity_provider GLOBAL ON (IDENTITY_PROVIDER), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql new file mode 100644 index 00000000..fef14ad0 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql @@ -0,0 +1,19 @@ +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), + INDEX idx_fed_consent_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/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql new file mode 100644 index 00000000..1f43b456 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FED_USER_CONSENT_CL_SCOPE +( + `USER_CONSENT_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + + INDEX idx_fed_consent_cl_scope_consent GLOBAL ON (USER_CONSENT_ID), + INDEX idx_fed_consent_cl_scope_scope GLOBAL ON (SCOPE_ID), +-- FOREIGN KEY (USER_CONSENT_ID) REFERENCES FED_USER_CONSENT (ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql new file mode 100644 index 00000000..9139ecf6 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql @@ -0,0 +1,20 @@ +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), + INDEX idx_fed_credential_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/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql new file mode 100644 index 00000000..f6cf32f2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql @@ -0,0 +1,15 @@ +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), + INDEX idx_fed_group_membership_group GLOBAL ON (GROUP_ID), + INDEX idx_fed_group_membership_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (GROUP_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql new file mode 100644 index 00000000..5ebf972f --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql @@ -0,0 +1,13 @@ +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), + INDEX idx_fed_req_action_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (REQUIRED_ACTION, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql new file mode 100644 index 00000000..dd8c6509 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql @@ -0,0 +1,15 @@ +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), + INDEX idx_fed_role_mapping_role GLOBAL ON (ROLE_ID), + INDEX idx_fed_role_mapping_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ROLE_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql new file mode 100644 index 00000000..45171a97 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FEDERATED_USER +( + `ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `REALM_ID` Utf8 NOT NULL, + + INDEX idx_federated_user_realm GLOBAL ON (REALM_ID), + INDEX idx_federated_user_storage GLOBAL ON (STORAGE_PROVIDER_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql new file mode 100644 index 00000000..9468c5ee --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql @@ -0,0 +1,17 @@ +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, + + INDEX idx_broker_link_realm GLOBAL ON (REALM_ID), + INDEX idx_broker_link_user GLOBAL ON (USER_ID), + INDEX idx_broker_link_provider GLOBAL ON (IDENTITY_PROVIDER), + INDEX idx_broker_link_broker_user GLOBAL ON (BROKER_USER_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql new file mode 100644 index 00000000..6023a20c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql @@ -0,0 +1,20 @@ +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), + INDEX idx_fed_user_attr_realm GLOBAL ON (REALM_ID), + INDEX idx_fed_user_attr_user GLOBAL ON (USER_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql new file mode 100644 index 00000000..12a7da1c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql @@ -0,0 +1,11 @@ +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), + INDEX idx_group_role_mapp_role GLOBAL ON (ROLE_ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ROLE_ID, GROUP_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql new file mode 100644 index 00000000..4f8b5dc2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql @@ -0,0 +1,19 @@ +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), + INDEX idx_res_serv_pol_type GLOBAL ON (TYPE), + INDEX idx_res_serv_pol_owner GLOBAL ON (OWNER), +-- 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/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql new file mode 100644 index 00000000..ae28bc93 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql @@ -0,0 +1,18 @@ +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), + INDEX idx_res_srv_res_owner GLOBAL ON (OWNER), +-- 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/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql new file mode 100644 index 00000000..75b054c2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql @@ -0,0 +1,12 @@ +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, + + INDEX idx_resource_attr_resource GLOBAL ON (RESOURCE_ID), + INDEX idx_resource_attr_name_value GLOBAL ON (NAME, VALUE), +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql new file mode 100644 index 00000000..0f57152a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql @@ -0,0 +1,11 @@ +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), + INDEX idx_res_policy_resource GLOBAL ON (RESOURCE_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/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql new file mode 100644 index 00000000..3ddeaded --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql @@ -0,0 +1,11 @@ +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), + INDEX idx_res_scope_resource GLOBAL ON (RESOURCE_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/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql new file mode 100644 index 00000000..28c8dbd1 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql @@ -0,0 +1,26 @@ +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), + INDEX idx_perm_ticket_resource GLOBAL ON (RESOURCE_ID), + INDEX idx_perm_ticket_resource_server GLOBAL ON (RESOURCE_SERVER_ID), + INDEX idx_perm_ticket_policy GLOBAL ON (POLICY_ID), + INDEX idx_perm_ticket_scope GLOBAL ON (SCOPE_ID), +-- 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/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql new file mode 100644 index 00000000..46237b8c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_URIS +( + `RESOURCE_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_resource_uris_resource GLOBAL ON (RESOURCE_ID), + INDEX idx_resource_uris_value GLOBAL ON (VALUE), +-- 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/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml index 1112dc98..7526d5fa 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml +++ b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml @@ -4,5 +4,226 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 566145fac6a4bbb34dffe9f8ce702e3d1ca4385a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:36:48 +0300 Subject: [PATCH 006/120] feat: Rewrite some functions, because ydb does not support FOR UPDATE --- .../ydb/keycloak/realm/YdbRealmProvider.kt | 104 +++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt index 47ad8878..23c07940 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -1,10 +1,110 @@ 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.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.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( - session: KeycloakSession, + private val keycloakSession: KeycloakSession, entityManager: EntityManager, -) : JpaRealmProvider(session, entityManager, null, null) + 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 + } +} From f89c356b000d9955fe61a60bf894283eee26a236 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:37:35 +0300 Subject: [PATCH 007/120] feat: Add YdbClientProviderFactory to use YdbRealmProvider in creation of ClientProviderFactory --- .../client/YdbClientProviderFactory.kt | 68 +++++++++++++++++++ .../org.keycloak.models.ClientProviderFactory | 1 + 2 files changed, 69 insertions(+) create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt new file mode 100644 index 00000000..6781fac9 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -0,0 +1,68 @@ +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.ProviderPriority.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") + if (searchableAttrsArr == null) { + val s = System.getProperty("keycloak.client.searchableAttributes") + searchableAttrsArr = s?.split("\\s*,\\s*".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + } + 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 + + 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/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory new file mode 100644 index 00000000..07dfd04f --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.client.YdbClientProviderFactory \ No newline at end of file From b33306107824e89b4ecdef813b05603a07b2f01e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:32:16 +0300 Subject: [PATCH 008/120] feat: Use hibernate-v7 dialect --- keycloak-ydb-extension/pom.xml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index 560fa287..c5b88e2f 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -23,7 +23,7 @@ 2.3.20 7.0.2 - 1.5.1 + 0.9.1 @@ -200,11 +200,8 @@ tech.ydb.dialects - hibernate-ydb-dialect - ${hibernate.ydb.dialect.version} - - - + hibernate-ydb-dialect-v7 + ${hibernate-v7.ydb.dialect.version} From 802f95aeac489ee9f11887fd55778efae8a44512 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:32:56 +0300 Subject: [PATCH 009/120] refactor: Make code more kotlin style --- .../tech/ydb/keycloak/client/YdbClientProviderFactory.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt index 6781fac9..2b90b4ee 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -20,10 +20,10 @@ class YdbClientProviderFactory : JpaClientProviderFactory() { private lateinit var clientSearchableAttributes: Set override fun init(config: Config.Scope) { - var searchableAttrsArr = config.getArray("searchableAttributes") + var searchableAttrsArr = config.getArray("searchableAttributes")?.toList() if (searchableAttrsArr == null) { val s = System.getProperty("keycloak.client.searchableAttributes") - searchableAttrsArr = s?.split("\\s*,\\s*".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + searchableAttrsArr = s?.split("\\s*,\\s*".toRegex()) } val s = HashSet(REQUIRED_SEARCHABLE_ATTRIBUTES) if (searchableAttrsArr != null) { From b88220c884b91939785c7594df9b5319e50a310f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:34:09 +0300 Subject: [PATCH 010/120] feat: Create ClientScopeProviderFactory implementation to return YdbRealmProvider instead of JpaRealmProvider --- .../client/YdbClientScopeProviderFactory.kt | 19 +++++++++++++++++++ ...keycloak.models.ClientScopeProviderFactory | 1 + 2 files changed, 20 insertions(+) create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt new file mode 100644 index 00000000..71edb3a0 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt @@ -0,0 +1,19 @@ +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.ProviderPriority.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 { + return PROVIDER_PRIORITY + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory new file mode 100644 index 00000000..ec4888dd --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.client.YdbClientScopeProviderFactory \ No newline at end of file From cff1d97bb7e5b7f0eb96bf95b18d399f596b493e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:35:04 +0300 Subject: [PATCH 011/120] feat: Implement removeClientScope without PESSIMISTIC_WRITE because ydb does not support FOR UPDATE --- .../ydb/keycloak/realm/YdbRealmProvider.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt index 23c07940..c5d8ec2c 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -4,11 +4,14 @@ 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 @@ -107,4 +110,34 @@ class YdbRealmProvider( 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 + } } From e19c0acad503406265997981c47d9e17fdc26269 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:35:30 +0300 Subject: [PATCH 012/120] chore: Add more migrations to support realm removing --- ...02-15-13-45-create-policy-config-table.sql | 11 ++++++++++ ...5-15-18-create-idp-mapper-config-table.sql | 11 ++++++++++ ...-21-create-client-initial-access-table.sql | 13 ++++++++++++ ...-create-user-federation-provider-table.sql | 17 +++++++++++++++ ...24-create-user-federation-config-table.sql | 11 ++++++++++ ...25-create-user-federation-mapper-table.sql | 15 +++++++++++++ ...te-user-federation-mapper-config-table.sql | 11 ++++++++++ .../resources/ydb/db.changelog-master.xml | 21 +++++++++++++++++++ 8 files changed, 110 insertions(+) create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql new file mode 100644 index 00000000..217936f1 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS POLICY_CONFIG +( + `POLICY_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + + INDEX idx_policy_config_policy GLOBAL ON (POLICY_ID), + INDEX idx_policy_config_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql new file mode 100644 index 00000000..fe2d5dac --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS IDP_MAPPER_CONFIG +( + `IDP_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_idp_mapper_config_mapper GLOBAL ON (IDP_MAPPER_ID), + INDEX idx_idp_mapper_config_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql new file mode 100644 index 00000000..dd0254f4 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql @@ -0,0 +1,17 @@ +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), + INDEX idx_usr_fed_prv_name GLOBAL ON (PROVIDER_NAME), + INDEX idx_usr_fed_prv_priority GLOBAL ON (PRIORITY), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql new file mode 100644 index 00000000..f8ca41a0 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_CONFIG +( + `USER_FEDERATION_PROVIDER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_user_fed_config_provider GLOBAL ON (USER_FEDERATION_PROVIDER_ID), + INDEX idx_user_fed_config_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql new file mode 100644 index 00000000..c532d015 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql @@ -0,0 +1,15 @@ +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), + INDEX idx_usr_fed_map_type GLOBAL ON (FEDERATION_MAPPER_TYPE), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql new file mode 100644 index 00000000..7d53e774 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER_CONFIG +( + `USER_FEDERATION_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_usr_fed_map_cfg_mapper GLOBAL ON (USER_FEDERATION_MAPPER_ID), + INDEX idx_usr_fed_map_cfg_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml index 7526d5fa..c3e4f154 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml +++ b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml @@ -226,4 +226,25 @@ + + + + + + + + + + + + + + + + + + + + + From ff31ec71f7224d7ab49d4b74541b3dd7d15a255e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 16 Feb 2026 18:20:46 +0300 Subject: [PATCH 013/120] feat: remove features-disabled from docker-compose.yml --- keycloak-ydb-extension/docker/docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 6f1b6d27..3951cbef 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -55,7 +55,6 @@ services: command: > -v start-dev --cache=local - --features-disabled=authorization,admin-fine-grained-authz,organization ports: - 9090:8080 networks: From 25d92f83c7a2d7c6ca81be4c10a7982eb7098a9f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 16 Feb 2026 18:22:45 +0300 Subject: [PATCH 014/120] refactor: Write keycloak correctly. Make error in run-keycloack-with-ydb.sh in english --- keycloak-ydb-extension/README.md | 2 +- .../{run-keycloack-with-ydb.sh => run-keycloak-with-ydb.sh} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename keycloak-ydb-extension/{run-keycloack-with-ydb.sh => run-keycloak-with-ydb.sh} (67%) diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 70b83059..745f0716 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -28,7 +28,7 @@ Alternative: you can set `KC_DB_URL` instead of `KC_YDB_URL`; the extension will From the project root: ```bash -./run-keycloack-with-ydb.sh +./run-keycloak-with-ydb.sh ``` This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. \ No newline at end of file diff --git a/keycloak-ydb-extension/run-keycloack-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh similarity index 67% rename from keycloak-ydb-extension/run-keycloack-with-ydb.sh rename to keycloak-ydb-extension/run-keycloak-with-ydb.sh index 2ec18c34..1dc0e82d 100644 --- a/keycloak-ydb-extension/run-keycloack-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -7,8 +7,8 @@ mkdir -p docker/providers JAR_FILE="target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" if [ ! -f "$JAR_FILE" ]; then - echo "Ошибка: Файл $JAR_FILE не найден!" - echo "Сборка проекта, возможно, завершилась неудачно." + echo "Error: File $JAR_FILE not found!" + echo "The project build may have failed." exit 1 fi From 66bfcc757fa763bff00339a531a9e25dfcdb2cf0 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 25 Feb 2026 23:59:50 +0300 Subject: [PATCH 015/120] refactor: Remove redundant IS_YDB_PROFILE_ENABLED --- keycloak-ydb-extension/README.md | 5 ++--- keycloak-ydb-extension/docker/docker-compose.yml | 1 - .../kotlin/tech/ydb/keycloak/config/YdbProfile.kt | 9 --------- .../connection/YdbConnectionProviderFactoryImpl.kt | 12 +----------- .../liquibase/YdbLiquibaseConnectionProvider.kt | 6 +----- .../ydb/keycloak/realm/YdbRealmProviderFactory.kt | 6 +----- 6 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 745f0716..3cf05900 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -11,12 +11,11 @@ When using YDB you must avoid giving the YDB URL to Keycloak’s default datasou | Variable | Value | Purpose | |----------|--------|---------| | `KC_DB` | `dev-file` | Built-in datasource uses dev-file; it never sees the YDB URL. | -| `KC_YDB_URL` | `jdbc:ydb:grpc://host:2136/database` | JDBC URL used by this extension. | -| `KC_COMMUNITY_DATASTORE_YDB_ENABLED` | `true` | Enables this extension’s JPA provider. | +| `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). | -Alternative: you can set `KC_DB_URL` instead of `KC_YDB_URL`; the extension will use it when the YDB profile is enabled. Then still set `KC_DB=dev-file` so the default pool is not created with that URL. +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. ## Getting started diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 3951cbef..b375052e 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -44,7 +44,6 @@ services: # YDB JDBC URL (config key ydbJdbcUrl / ydb-url; env KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL). - KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local - KC_SPI_CONNECTIONS_JPA_SHOW_SQL=true - - KC_COMMUNITY_DATASTORE_YDB_ENABLED=true # Disable default JPA and Liquibase providers so H2 is not used (only YDB is used). - KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED=false - KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED=false diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt deleted file mode 100644 index 170f3bc2..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt +++ /dev/null @@ -1,9 +0,0 @@ -package tech.ydb.keycloak.config - -object YdbProfile { - private const val ENV_YDB_PROFILE_ENABLED: String = "KC_COMMUNITY_DATASTORE_YDB_ENABLED" - private const val PROP_YDB_PROFILE_ENABLED: String = "kc.community.datastore.ydb.enabled" - - val IS_YDB_PROFILE_ENABLED = System.getenv(ENV_YDB_PROFILE_ENABLED).toBoolean() - || System.getProperty(PROP_YDB_PROFILE_ENABLED).toBoolean() -} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt index c02fc94d..b69193b7 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -22,12 +22,10 @@ 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.EnvironmentDependentProviderFactory import org.keycloak.provider.ServerInfoAwareProviderFactory import tech.ydb.hibernate.dialect.YdbDialect import tech.ydb.jdbc.YdbDriver import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY -import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl.Companion.MigrationStrategy.* import java.io.File import java.sql.Connection @@ -35,9 +33,7 @@ import java.sql.DriverManager import java.util.* import kotlin.properties.Delegates -class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, - ServerInfoAwareProviderFactory, - EnvironmentDependentProviderFactory { +class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInfoAwareProviderFactory { private val logger: Logger = Logger.getLogger(YdbConnectionProviderFactoryImpl::class.java) @@ -65,10 +61,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, } override fun init(scope: Config.Scope) { - if (!isSupported(scope)) { - logger.debug("YDB JPA disabled (profile not enabled), skipping init") - return - } config = scope } @@ -156,8 +148,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, override fun order(): Int = PROVIDER_PRIORITY - override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - override fun getConnection(): Connection { try { val url = resolveJdbcUrl() diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt index a2571998..77110bc8 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -9,13 +9,11 @@ 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 org.keycloak.provider.EnvironmentDependentProviderFactory import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY -import tech.ydb.keycloak.config.YdbProfile import tech.ydb.liquibase.database.YdbDatabase import java.sql.Connection -class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider(), EnvironmentDependentProviderFactory { +class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider() { private var indexCreationThreshold: Long = DEFAULT_INDEX_CREATION_THRESHOLD @@ -28,8 +26,6 @@ class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider(), Env override fun order(): Int = PROVIDER_PRIORITY - override fun isSupported(scope: Config.Scope): Boolean = YdbProfile.IS_YDB_PROFILE_ENABLED - override fun getLiquibase(connection: Connection, defaultSchema: String?): KeycloakLiquibase { val database = newYdbDatabase(connection) if (!defaultSchema.isNullOrBlank()) { diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 807f96ef..2020f9ab 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -5,12 +5,10 @@ import org.keycloak.Config import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.models.RealmProviderFactory -import org.keycloak.provider.EnvironmentDependentProviderFactory import org.keycloak.connections.jpa.JpaConnectionProvider import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY -import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -class YdbRealmProviderFactory() : RealmProviderFactory, EnvironmentDependentProviderFactory { +class YdbRealmProviderFactory() : RealmProviderFactory { private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) @@ -38,8 +36,6 @@ class YdbRealmProviderFactory() : RealmProviderFactory, Enviro override fun getId(): String = ID - override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - override fun order(): Int = PROVIDER_PRIORITY private companion object { From 6b383b0c967c1577f486bfa4ed868e1b2183d116 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:03:41 +0300 Subject: [PATCH 016/120] refactor: Deleted redundant files --- .../connection/YdbConnectionProvider.kt | 5 ----- .../connection/YdbConnectionProviderFactory.kt | 5 ----- .../ydb/keycloak/connection/YdbConnectionSpi.kt | 17 ----------------- .../META-INF/services/org.keycloak.provider.Spi | 1 - 4 files changed, 28 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt delete mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt deleted file mode 100644 index c4de0290..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package tech.ydb.keycloak.connection - -import org.keycloak.provider.Provider - -interface YdbConnectionProvider : Provider diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt deleted file mode 100644 index c7ba02de..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt +++ /dev/null @@ -1,5 +0,0 @@ -package tech.ydb.keycloak.connection - -import org.keycloak.provider.ProviderFactory - -interface YdbConnectionProviderFactory : ProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt deleted file mode 100644 index 1e7e341e..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt +++ /dev/null @@ -1,17 +0,0 @@ -package tech.ydb.keycloak.connection - -import org.keycloak.provider.Spi - -class YdbConnectionSpi : Spi { - override fun isInternal(): Boolean = true - - override fun getName() = NAME - - override fun getProviderClass() = YdbConnectionProvider::class.java - - override fun getProviderFactoryClass() = YdbConnectionProviderFactory::class.java - - companion object { - private const val NAME: String = "ydbConnection" - } -} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi deleted file mode 100644 index 9321db82..00000000 --- a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ /dev/null @@ -1 +0,0 @@ -tech.ydb.keycloak.connection.YdbConnectionSpi \ No newline at end of file From d7daf7fe0af60be5e48627e1a270e7f3792db827 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:08:45 +0300 Subject: [PATCH 017/120] refactor: Make PROVIDER_PRIORITY bigger to show that only YDB factories are used --- .../main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt index 7abe5c15..f8f17425 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt @@ -1,6 +1,5 @@ package tech.ydb.keycloak.config object ProviderPriority { - // more than in quarkus provider factories - const val PROVIDER_PRIORITY = 200 + const val PROVIDER_PRIORITY = Int.MAX_VALUE } From e1ff4e7e8525196f6459b4f4e596910cfee714de Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:27:31 +0300 Subject: [PATCH 018/120] refactor: Use keycloak.conf file to pass all configs --- keycloak-ydb-extension/README.md | 3 ++- .../docker/conf/keycloak.conf | 18 ++++++++++++++++++ .../docker/docker-compose.yml | 12 +----------- .../YdbConnectionProviderFactoryImpl.kt | 3 +-- 4 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 keycloak-ydb-extension/docker/conf/keycloak.conf diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 3cf05900..5f8b69f5 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -30,4 +30,5 @@ From the project root: ./run-keycloak-with-ydb.sh ``` -This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. \ No newline at end of file +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/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf new file mode 100644 index 00000000..cc9f0ea3 --- /dev/null +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -0,0 +1,18 @@ +# 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-default-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local +spi-connections-jpa-default-show-sql=true + +# YDB Liquibase provider: index creation threshold (optional, default 300000) +# 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 + +# --- Dev mode admin (optional; for start-dev) --- +keycloak-admin=admin +keycloak-admin-password=admin diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index b375052e..08147aba 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -39,17 +39,7 @@ services: image: quay.io/keycloak/keycloak:26.4.7 volumes: - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar - environment: - # Use dev-file for the built-in datasource so it never sees the YDB URL (no "driver does not support URL" error). - # YDB JDBC URL (config key ydbJdbcUrl / ydb-url; env KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL). - - KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local - - KC_SPI_CONNECTIONS_JPA_SHOW_SQL=true - # Disable default JPA and Liquibase providers so H2 is not used (only YDB is used). - - KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED=false - - KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED=false - # Admin - - KEYCLOAK_ADMIN=admin - - KEYCLOAK_ADMIN_PASSWORD=admin + - ./conf/keycloak.conf:/opt/keycloak/conf/keycloak.conf entrypoint: /opt/keycloak/bin/kc.sh command: > -v start-dev diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt index b69193b7..8278153d 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -78,9 +78,8 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) } } - // TODO: FIND OUT HOW IT SMTH LIKE spi.connections-jpa.default.ydb-jdbc-url private fun resolveJdbcUrl(): String = requireNotNull(config["ydbJdbcUrl"]) { - " YDB JDBC URL is required. Set env variable KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL= " + "YDB JDBC URL is required" } private fun createOrUpdateSchema( From 7dda6c023c7e604ab2c9db8295055c0fc6bb17ce Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:34:23 +0300 Subject: [PATCH 019/120] refactor: Set "ydb" id in all providers --- keycloak-ydb-extension/docker/conf/keycloak.conf | 4 ++-- .../ydb/keycloak/client/YdbClientProviderFactory.kt | 5 ++++- .../keycloak/client/YdbClientScopeProviderFactory.kt | 11 ++++++----- .../config/{ProviderPriority.kt => ProviderConfig.kt} | 3 ++- .../connection/YdbConnectionProviderFactoryImpl.kt | 7 ++++--- .../liquibase/YdbLiquibaseConnectionProvider.kt | 4 ++-- .../ydb/keycloak/realm/YdbRealmProviderFactory.kt | 9 +++------ 7 files changed, 23 insertions(+), 20 deletions(-) rename keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/{ProviderPriority.kt => ProviderConfig.kt} (59%) diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index cc9f0ea3..bf19fde4 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -3,8 +3,8 @@ # --- YDB / JPA (this extension) --- # JDBC URL for YDB (required). Override with env for different hosts, e.g.: -spi-connections-jpa-default-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local -spi-connections-jpa-default-show-sql=true +spi-connections-jpa-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local +spi-connections-jpa-ydb-show-sql=true # YDB Liquibase provider: index creation threshold (optional, default 300000) # spi-connections-liquibase-ydb-liquibase-index-creation-threshold=300000 diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt index 2b90b4ee..68f72dcb 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -12,7 +12,8 @@ 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.ProviderPriority.PROVIDER_PRIORITY +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() { @@ -57,6 +58,8 @@ class YdbClientProviderFactory : JpaClientProviderFactory() { 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", diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt index 71edb3a0..ed2b416e 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt @@ -4,16 +4,17 @@ 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.ProviderPriority.PROVIDER_PRIORITY +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() { +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 { - return PROVIDER_PRIORITY - } + override fun order(): Int = PROVIDER_PRIORITY + + override fun getId(): String = PROVIDER_ID } \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt similarity index 59% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt rename to keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt index f8f17425..725b63f6 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt @@ -1,5 +1,6 @@ package tech.ydb.keycloak.config -object ProviderPriority { +object ProviderConfig { const val PROVIDER_PRIORITY = Int.MAX_VALUE + const val PROVIDER_ID = "ydb" } diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt index 8278153d..f6b4f826 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -25,7 +25,8 @@ import org.keycloak.models.utils.KeycloakModelUtils import org.keycloak.provider.ServerInfoAwareProviderFactory import tech.ydb.hibernate.dialect.YdbDialect import tech.ydb.jdbc.YdbDriver -import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +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 @@ -78,7 +79,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) } } - private fun resolveJdbcUrl(): String = requireNotNull(config["ydbJdbcUrl"]) { + private fun resolveJdbcUrl(): String = requireNotNull(config["jdbcUrl"]) { "YDB JDBC URL is required" } @@ -143,7 +144,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf } } - override fun getId(): String = "default" + override fun getId(): String = PROVIDER_ID override fun order(): Int = PROVIDER_PRIORITY diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt index 77110bc8..3a9ae424 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -9,7 +9,8 @@ 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.ProviderPriority.PROVIDER_PRIORITY +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 @@ -68,7 +69,6 @@ class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider() { } companion object { - const val PROVIDER_ID: String = "ydb-liquibase" const val YDB_MASTER_CHANGELOG: String = "ydb/db.changelog-master.xml" private const val DEFAULT_INDEX_CREATION_THRESHOLD = 300000L diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 2020f9ab..5ab4f1c5 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -6,7 +6,8 @@ import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.models.RealmProviderFactory import org.keycloak.connections.jpa.JpaConnectionProvider -import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY class YdbRealmProviderFactory() : RealmProviderFactory { @@ -34,11 +35,7 @@ class YdbRealmProviderFactory() : RealmProviderFactory { // no operations } - override fun getId(): String = ID + override fun getId(): String = PROVIDER_ID override fun order(): Int = PROVIDER_PRIORITY - - private companion object { - private const val ID = "ydb-realm-provider-factory" - } } From e1889ee49da8469f6e291c13a4150e84fd28216f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:54:48 +0300 Subject: [PATCH 020/120] chore: Move files from the root to core module --- keycloak-ydb-extension/{ => core}/pom.xml | 0 .../kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt | 0 .../tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt | 0 .../src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt | 0 .../ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt | 0 .../tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt | 0 .../src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt | 0 .../kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt | 0 .../org.keycloak.connections.jpa.JpaConnectionProviderFactory | 0 ....jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory | 0 .../META-INF/services/org.keycloak.models.ClientProviderFactory | 0 .../services/org.keycloak.models.ClientScopeProviderFactory | 0 .../META-INF/services/org.keycloak.models.RealmProviderFactory | 0 .../ydb/changesets/2025-12-17-21-07-create-realm-table.sql | 0 .../changesets/2025-12-17-21-29-create-realm-attributes-table.sql | 0 .../2025-12-17-22-00-create-realm-required-credentials-table.sql | 0 .../2025-12-20-00-20-create-realm_events_listeners-table.sql | 0 .../2025-12-20-00-21-create-realm-default-groups-table.sql | 0 .../2025-12-20-00-28-create-realm-enabled-event-types-table.sql | 0 .../2025-12-20-00-29-create-realm-localizations-table.sql | 0 .../2025-12-20-00-30-create-realm-smtp-config-table.sql | 0 .../2025-12-20-00-31-create-realm-supported-locales-table.sql | 0 .../2025-12-20-00-34-create-default-client-scope-table.sql | 0 .../2025-12-20-00-36-create-authentication-flow-table.sql | 0 .../2025-12-20-00-38-create-authentication-execution-table.sql | 0 .../2025-12-20-00-42-create-authenticator-config-table.sql | 0 .../2025-12-20-00-44-create-required-action-provider-table.sql | 0 .../2025-12-20-00-45-create-required-action-config-table.sql | 0 .../2025-12-20-00-46-create-authenticator-config-entry-table.sql | 0 .../ydb/changesets/2025-12-20-00-47-create-component-table.sql | 0 .../changesets/2025-12-20-00-48-create-component-config-table.sql | 0 .../ydb/changesets/2025-12-20-01-00-create-client-table.sql | 0 .../ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql | 0 .../ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql | 0 .../ydb/changesets/2026-02-01-16-04-composite-role-table.sql | 0 .../ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql | 0 .../changesets/2026-02-06-06-00-create-migration-model-table.sql | 0 .../2026-02-06-21-24-create-client-attributes-table.sql | 0 .../changesets/2026-02-06-21-34-create-redirect-uris-table.sql | 0 .../changesets/2026-02-06-21-35-create-protocol-mappers-table.sql | 0 .../2026-02-06-21-38-create-protocol-mapper-config-table.sql | 0 .../changesets/2026-02-07-02-06-create-scope-mapping-table.sql | 0 .../ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql | 0 .../ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql | 0 .../2026-02-07-15-50-create-client-scope-attributes-table.sql | 0 .../2026-02-07-15-51-create-client-scope-client-table.sql | 0 .../2026-02-07-15-52-create-client-scope-role-mapping-table.sql | 0 .../ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql | 0 .../2026-02-07-16-02-create-user-role-mapping-table.sql | 0 .../2026-02-07-16-05-create-identity-provider-table.sql | 0 .../2026-02-07-16-06-create-identity-provider-config-table.sql | 0 .../2026-02-07-16-07-create-identity-provider-mapper-table.sql | 0 .../ydb/changesets/2026-02-07-16-09-create-credential-table.sql | 0 .../changesets/2026-02-07-16-11-create-user-attribute-table.sql | 0 .../changesets/2026-02-07-16-15-create-revoked-token-table.sql | 0 .../2026-02-07-16-19-create-client-auth-flow-bindings-table.sql | 0 .../2026-02-07-16-21-create-client-node-registrations-table.sql | 0 .../2026-02-07-16-24-create-user-required-action-table.sql | 0 .../2026-02-07-16-26-create-offline-client-session-table.sql | 0 .../2026-02-07-16-27-create-offline-user-session-table.sql | 0 .../2026-02-07-16-37-create-user-group-membership-table.sql | 0 .../changesets/2026-02-07-16-40-create-keycloak-group-table.sql | 0 .../ydb/changesets/2026-02-07-16-53-create-org-table.sql | 0 .../ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql | 0 .../ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql | 0 .../2026-02-07-17-05-create-user-consent-client-scope-table.sql | 0 .../2026-02-07-17-08-create-federated-identity-table.sql | 0 .../changesets/2026-02-07-17-09-create-fed-user-consent-table.sql | 0 .../2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql | 0 .../2026-02-07-17-11-create-fed-user-credential-table.sql | 0 .../2026-02-07-17-12-create-fed-user-group-membership-table.sql | 0 .../2026-02-07-17-12-create-fed-user-required-action-table.sql | 0 .../2026-02-07-17-13-create-fed-user-role-mapping-table.sql | 0 .../changesets/2026-02-07-17-14-create-federated-user-table.sql | 0 .../ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql | 0 .../2026-02-07-17-16-create-fed-user-attribute-table.sql | 0 .../changesets/2026-02-07-17-17-create-group-attribute-table.sql | 0 .../2026-02-07-17-18-create-group-role-mapping-table.sql | 0 .../changesets/2026-02-08-19-53-create-resource-server-table.sql | 0 .../2026-02-08-19-54-create-resource-server-policy-table.sql | 0 .../2026-02-08-19-56-create-resource-server-resource-table.sql | 0 .../2026-02-08-19-57-create-resource-attribute-table.sql | 0 .../changesets/2026-02-08-19-58-create-resource-policy-table.sql | 0 .../2026-02-08-19-59-create-resource-server-scope-table.sql | 0 .../changesets/2026-02-08-20-00-create-resource-scope-table.sql | 0 .../2026-02-08-20-01-create-resource-server-perm-ticket-table.sql | 0 .../changesets/2026-02-08-20-02-create-resource-uris-table.sql | 0 .../changesets/2026-02-15-13-45-create-policy-config-table.sql | 0 .../2026-02-15-15-18-create-idp-mapper-config-table.sql | 0 .../2026-02-15-15-21-create-client-initial-access-table.sql | 0 .../2026-02-15-15-23-create-user-federation-provider-table.sql | 0 .../2026-02-15-15-24-create-user-federation-config-table.sql | 0 .../2026-02-15-15-25-create-user-federation-mapper-table.sql | 0 ...026-02-15-15-29-create-user-federation-mapper-config-table.sql | 0 .../{ => core}/src/main/resources/ydb/db.changelog-master.xml | 0 95 files changed, 0 insertions(+), 0 deletions(-) rename keycloak-ydb-extension/{ => core}/pom.xml (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/db.changelog-master.xml (100%) diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/core/pom.xml similarity index 100% rename from keycloak-ydb-extension/pom.xml rename to keycloak-ydb-extension/core/pom.xml diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml rename to keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml From accd2ede61e970a6629ea32e0ae31be87bfc33b7 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 19:22:32 +0300 Subject: [PATCH 021/120] test: Implement RealmModelTest --- keycloak-ydb-extension/core/pom.xml | 40 +- keycloak-ydb-extension/pom.xml | 107 ++++ .../run-keycloak-with-ydb.sh | 4 +- keycloak-ydb-extension/test/pom.xml | 164 ++++++ .../tech/ydb/keycloak/testsuite/Config.java | 162 ++++++ .../testsuite/KeycloakModelParameters.java | 58 ++ .../keycloak/testsuite/KeycloakModelTest.java | 494 ++++++++++++++++++ .../keycloak/testsuite/RealmModelTest.java | 166 ++++++ .../keycloak/testsuite/RequireProvider.java | 16 + .../keycloak/testsuite/RequireProviders.java | 12 + .../keycloak/testsuite/parameters/Ydb.java | 157 ++++++ .../test/src/test/resources/log4j.properties | 75 +++ 12 files changed, 1424 insertions(+), 31 deletions(-) create mode 100644 keycloak-ydb-extension/pom.xml create mode 100644 keycloak-ydb-extension/test/pom.xml create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelParameters.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RealmModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProvider.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProviders.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java create mode 100644 keycloak-ydb-extension/test/src/test/resources/log4j.properties diff --git a/keycloak-ydb-extension/core/pom.xml b/keycloak-ydb-extension/core/pom.xml index c5b88e2f..16092c55 100644 --- a/keycloak-ydb-extension/core/pom.xml +++ b/keycloak-ydb-extension/core/pom.xml @@ -4,28 +4,18 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - tech.ydb + + tech.ydb + keycloak-ydb-extension-parent + 1.0-SNAPSHOT + ../pom.xml + + + jar + keycloak-ydb-extension (core) keycloak-ydb-extension 1.0-SNAPSHOT - - 21 - 21 - 21 - UTF-8 - - 2.2.21 - official - 21 - - 26.4.7 - - 2.3.20 - - 7.0.2 - 0.9.1 - - src/main/kotlin src/test/kotlin @@ -95,15 +85,7 @@ - maven-surefire-plugin - 3.5.4 - - - maven-failsafe-plugin - 3.5.4 - - - org.apache.maven.plugins + maven-shade-plugin 3.5.0 @@ -229,4 +211,4 @@ test - \ 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..a64450f9 --- /dev/null +++ b/keycloak-ydb-extension/pom.xml @@ -0,0 +1,107 @@ + + + 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 + + + + 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.1 + + + + + + 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/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index 1dc0e82d..e7cd4ef9 100644 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -1,10 +1,10 @@ rm -f docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar -mvn clean package +mvn -f core/pom.xml clean package mkdir -p docker/providers -JAR_FILE="target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" +JAR_FILE="core/target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" if [ ! -f "$JAR_FILE" ]; then echo "Error: File $JAR_FILE not found!" diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml new file mode 100644 index 00000000..f0386f8a --- /dev/null +++ b/keycloak-ydb-extension/test/pom.xml @@ -0,0 +1,164 @@ + + 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 + + + org.junit.jupiter + junit-jupiter + test + + + tech.ydb.test + ydb-junit5-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 + + + tech.ydb.keycloak.testsuite.parameters.Ydb + 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/testsuite/Config.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java new file mode 100644 index 00000000..ac892b7c --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java @@ -0,0 +1,162 @@ +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) { + String fullKey = prefix + key; + String v = replaceProperties(getConfig().get(fullKey)); + if (v == null || v.isEmpty()) { + v = System.getProperty("keycloak." + fullKey); + } + return v != null && !v.isEmpty() ? v : null; + } + + @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/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..033a1784 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java @@ -0,0 +1,494 @@ +package tech.ydb.keycloak.testsuite; + +import com.google.common.collect.ImmutableSet; +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.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.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.BiConsumer; +import java.util.function.BiFunction; +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; + +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 = ImmutableSet.>builder() + .add(AuthorizationSpi.class) + .add(PolicySpi.class) + .add(ClientScopeSpi.class) + .add(ClientSpi.class) + .add(ComponentFactorySpi.class) + .add(EventStoreSpi.class) + .add(ExecutorsSpi.class) + .add(GroupSpi.class) + .add(RealmSpi.class) + .add(RoleSpi.class) + .add(DeploymentStateSpi.class) + .add(StoreFactorySpi.class) + .add(TimerSpi.class) + .add(UserLoginFailureSpi.class) + .add(UserSessionSpi.class) + .add(UserSpi.class) + .add(DatastoreSpi.class) + .add(TracingSpi.class) + .build(); + + private static final Set> ALLOWED_FACTORIES = + ImmutableSet.>builder() + .add(ComponentFactoryProviderFactory.class) + .add(DefaultAuthorizationProviderFactory.class) + .add(PolicyProviderFactory.class) + .add(DefaultExecutorsProviderFactory.class) + .add(DeploymentStateProviderFactory.class) + .add(DatastoreProviderFactory.class) + .add(TracingProviderFactory.class) + .build(); + + 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 R inComittedTransaction(T parameter, BiFunction what) { + return inComittedTransaction(parameter, what, 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); + } + + protected R inComittedTransaction( + T parameter, BiFunction what, BiConsumer onCommit) { + 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() { + } + }); + 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 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.realms().removeRealm(realm.getId()); + } + return s.realms().createRealm(name); + } +} 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/parameters/Ydb.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java new file mode 100644 index 00000000..38a670eb --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java @@ -0,0 +1,157 @@ +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.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(LiquibaseConnectionProviderFactory.class) + .add(LiquibaseDBLockProviderFactory.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("group").defaultProvider("jpa") + .spi("idp").defaultProvider("jpa") + .spi("role").defaultProvider("jpa") + .spi("user").defaultProvider("jpa") + .spi("deploymentState").defaultProvider("jpa") + .spi("dblock").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/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 + From a4834767db9e4327cd539ba315c8f80b57e3e681 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 22:39:43 +0300 Subject: [PATCH 022/120] test: Add TimeOffsetTest --- .../keycloak/testsuite/KeycloakModelTest.java | 10 +++ .../keycloak/testsuite/TimeOffsetTest.java | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/TimeOffsetTest.java 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 index 033a1784..c934a4d1 100644 --- 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 @@ -491,4 +491,14 @@ protected static RealmModel createRealm(KeycloakSession s, String name) { } 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); + }); + } } 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; + }); + } +} From 63e44615a727df28f5a7caefc4cf0509be8d719d Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 22:39:53 +0300 Subject: [PATCH 023/120] test: Add ClientModelTest --- .../testsuite/client/ClientModelTest.java | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/client/ClientModelTest.java 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; + }); + } +} From ff14620e38ab13acf7af4ec16d50951193290b30 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 23:18:41 +0300 Subject: [PATCH 024/120] test: Add GroupModelTest --- .../testsuite/group/GroupModelTest.java | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java 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..d56ff81d --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java @@ -0,0 +1,212 @@ +package tech.ydb.keycloak.testsuite.group; + +import org.junit.Ignore; +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 + @Ignore("Implement YdbRealmProvider.removeGroup or use ignore FOR UPDATE in hibernate-dialect") + 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; + }); + } +} From de44952d8677226a06d42bce60d0fedacec1ff6f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 23:24:27 +0300 Subject: [PATCH 025/120] test: Add ClientScopeModelTest --- keycloak-ydb-extension/test/pom.xml | 2 +- .../keycloak/testsuite/KeycloakModelTest.java | 66 +++++----- .../clientscope/ClientScopeModelTest.java | 107 ++++++++++++++++ .../testsuite/parameters/Infinispan.java | 113 +++++++++++++++++ .../test/src/test/resources/test-ispn.xml | 115 ++++++++++++++++++ 5 files changed, 371 insertions(+), 32 deletions(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Infinispan.java create mode 100644 keycloak-ydb-extension/test/src/test/resources/test-ispn.xml diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index f0386f8a..6175b045 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -152,7 +152,7 @@ -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED - tech.ydb.keycloak.testsuite.parameters.Ydb + 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/testsuite/KeycloakModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java index c934a4d1..f05bcd5a 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -34,6 +35,8 @@ 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; @@ -161,37 +164,38 @@ protected void finished(Description description) { } }; - private static final Set> ALLOWED_SPIS = ImmutableSet.>builder() - .add(AuthorizationSpi.class) - .add(PolicySpi.class) - .add(ClientScopeSpi.class) - .add(ClientSpi.class) - .add(ComponentFactorySpi.class) - .add(EventStoreSpi.class) - .add(ExecutorsSpi.class) - .add(GroupSpi.class) - .add(RealmSpi.class) - .add(RoleSpi.class) - .add(DeploymentStateSpi.class) - .add(StoreFactorySpi.class) - .add(TimerSpi.class) - .add(UserLoginFailureSpi.class) - .add(UserSessionSpi.class) - .add(UserSpi.class) - .add(DatastoreSpi.class) - .add(TracingSpi.class) - .build(); - - private static final Set> ALLOWED_FACTORIES = - ImmutableSet.>builder() - .add(ComponentFactoryProviderFactory.class) - .add(DefaultAuthorizationProviderFactory.class) - .add(PolicyProviderFactory.class) - .add(DefaultExecutorsProviderFactory.class) - .add(DeploymentStateProviderFactory.class) - .add(DatastoreProviderFactory.class) - .add(TracingProviderFactory.class) - .build(); + 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); 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/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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0b7c1d0f160003d57006adac5a2c5ab0632819a0 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 23:34:26 +0300 Subject: [PATCH 026/120] test: Add RoleModelTest --- .../testsuite/role/RoleModelTest.java | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java 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..32a2fdbf --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java @@ -0,0 +1,399 @@ +package tech.ydb.keycloak.testsuite.role; + +import org.hamcrest.Matcher; +import org.junit.Assume; +import org.junit.Ignore; +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 + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + public void testRolesWithIdsSearchQueries() { + testRolesWithIdsSearchQueries(this::getResult); + } + + @Test + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + 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 + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + public void testRolesWithIdsPaginationSearchQueries() { + testRolesWithIdsPaginationSearchQueries(this::getResult); + } + + @Test + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + 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 getRolePathTraversal() { + // Only perform this test if realm role ID = role.name and client role ID = client.id + ":" + role.name + Assume.assumeThat(mainRoleId, is(MAIN_ROLE_NAME)); + Assume.assumeTrue(rolesSubset.stream().anyMatch((CLIENT_NAME + ":" + ROLE_PREFIX + "10")::equals)); + + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().getRoleById(realm, (CLIENT_NAME + ":" + ROLE_PREFIX + "10") + "/../../" + MAIN_ROLE_NAME); + assertThat(role, nullValue()); + 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); + } +} From 2aebd51cd04a63cdd572689b15180d9317046a56 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 6 Mar 2026 00:13:05 +0300 Subject: [PATCH 027/120] test: Add ClientScopeStorageTest --- keycloak-ydb-extension/test/pom.xml | 2 +- .../clientscope/ClientScopeStorageTest.java | 59 ++++++ .../HardcodedClientScopeStorageProvider.java | 179 ++++++++++++++++++ ...odedClientScopeStorageProviderFactory.java | 42 ++++ .../testsuite/parameters/YdbFederation.java | 78 ++++++++ ...entscope.ClientScopeStorageProviderFactory | 1 + 6 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java create mode 100644 keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index 6175b045..f7803198 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -152,7 +152,7 @@ -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED - Ydb,Infinispan + Ydb,YdbFederation,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/testsuite/clientscope/ClientScopeStorageTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java new file mode 100644 index 00000000..76ef538a --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java @@ -0,0 +1,59 @@ +package tech.ydb.keycloak.testsuite.clientscope; + +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.*; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.clientscope.ClientScopeStorageProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +@RequireProvider(RealmProvider.class) +@RequireProvider(ClientScopeStorageProvider.class) +public class ClientScopeStorageTest extends KeycloakModelTest { + + private String realmId; + private String clientScopeFederationId; + + @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 testGetClientScopeById() { + getParameters(ClientScopeStorageProviderModel.class).forEach(fs -> inComittedTransaction(fs, (session, federatedStorage) -> { + Assume.assumeThat("Cannot handle more than 1 client scope federation provider", clientScopeFederationId, Matchers.nullValue()); + RealmModel realm = session.realms().getRealm(realmId); + federatedStorage.setParentId(realmId); + federatedStorage.setEnabled(true); + federatedStorage.getConfig().putSingle(HardcodedClientScopeStorageProviderFactory.SCOPE_NAME, HardcodedClientScopeStorageProviderFactory.SCOPE_NAME); + ComponentModel res = realm.addComponentModel(federatedStorage); + clientScopeFederationId = res.getId(); + log.infof("Added %s client scope federation provider: %s", federatedStorage.getName(), clientScopeFederationId); + return null; + })); + + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + StorageId storageId = new StorageId(clientScopeFederationId, "scope_name"); + ClientScopeModel hardcoded = session.clientScopes().getClientScopeById(realm, storageId.getId()); + Assert.assertNotNull(hardcoded); + return null; + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java new file mode 100644 index 00000000..93041bb8 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java @@ -0,0 +1,179 @@ +/* + * Copyright 2021 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. + */ +package tech.ydb.keycloak.testsuite.clientscope; + +import org.keycloak.models.*; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.clientscope.ClientScopeLookupProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Stream; + + +public class HardcodedClientScopeStorageProvider implements ClientScopeStorageProvider, ClientScopeLookupProvider { + + private final ClientScopeStorageProviderModel component; + private final String clientScopeName; + + public HardcodedClientScopeStorageProvider(KeycloakSession session, ClientScopeStorageProviderModel component) { + this.component = component; + this.clientScopeName = component.getConfig().getFirst(HardcodedClientScopeStorageProviderFactory.SCOPE_NAME); + } + + @Override + public ClientScopeModel getClientScopeById(RealmModel realm, String id) { + StorageId storageId = new StorageId(id); + final String scopeName = storageId.getExternalId(); + if (this.clientScopeName.equals(scopeName)) return new HardcodedClientScopeAdapter(realm); + return null; + } + + @Override + public void close() { + } + + public class HardcodedClientScopeAdapter implements ClientScopeModel { + + private final RealmModel realm; + private StorageId storageId; + + public HardcodedClientScopeAdapter(RealmModel realm) { + this.realm = realm; + } + + @Override + public String getId() { + if (storageId == null) { + storageId = new StorageId(component.getId(), getName()); + } + return storageId.getId(); + } + + @Override + public String getName() { + return clientScopeName; + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public void setName(String name) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public String getDescription() { + return "Federated client scope"; + } + + @Override + public void setDescription(String description) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public String getProtocol() { + return "openid-connect"; + } + + @Override + public void setProtocol(String protocol) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void setAttribute(String name, String value) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void removeAttribute(String name) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public String getAttribute(String name) { + return null; + } + + @Override + public Map getAttributes() { + return Collections.EMPTY_MAP; + } + + @Override + public Stream getProtocolMappersStream() { + return Stream.empty(); + } + + @Override + public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void removeProtocolMapper(ProtocolMapperModel mapping) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void updateProtocolMapper(ProtocolMapperModel mapping) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public ProtocolMapperModel getProtocolMapperById(String id) { + return null; + } + + @Override + public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) { + return null; + } + + @Override + public Stream getScopeMappingsStream() { + return Stream.empty(); + } + + @Override + public Stream getRealmScopeMappingsStream() { + return Stream.empty(); + } + + @Override + public void addScopeMapping(RoleModel role) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void deleteScopeMapping(RoleModel role) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public boolean hasScope(RoleModel role) { + return false; + } + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java new file mode 100644 index 00000000..957c5cc3 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java @@ -0,0 +1,42 @@ +package tech.ydb.keycloak.testsuite.clientscope; + +import java.util.List; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; + +public class HardcodedClientScopeStorageProviderFactory implements ClientScopeStorageProviderFactory { + + public static final String PROVIDER_ID = "hardcoded-clientscope"; + public static final String SCOPE_NAME = "scope_name"; + protected static final List CONFIG_PROPERTIES; + + @Override + public HardcodedClientScopeStorageProvider create(KeycloakSession session, ComponentModel model) { + return new HardcodedClientScopeStorageProvider(session, new ClientScopeStorageProviderModel(model)); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + static { + CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property().name(SCOPE_NAME) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Hardcoded Scope Name") + .helpText("Only this scope name is available for lookup") + .defaultValue("hardcoded-clientscope") + .add() + .build(); + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java new file mode 100644 index 00000000..2dc258f8 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java @@ -0,0 +1,78 @@ +/* + * Copyright 2020 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. + */ +package tech.ydb.keycloak.testsuite.parameters; + +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; +import org.keycloak.storage.UserStorageProviderSpi; +import org.keycloak.storage.federated.UserFederatedStorageProviderSpi; +import org.keycloak.storage.jpa.JpaUserFederatedStorageProviderFactory; +import com.google.common.collect.ImmutableSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.keycloak.storage.clientscope.ClientScopeStorageProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi; +import tech.ydb.keycloak.testsuite.Config; +import tech.ydb.keycloak.testsuite.KeycloakModelParameters; +import tech.ydb.keycloak.testsuite.clientscope.HardcodedClientScopeStorageProviderFactory; + +/** + * + * @author hmlnarik + */ +public class YdbFederation extends KeycloakModelParameters { + + private final AtomicInteger counter = new AtomicInteger(); + + static final Set> ALLOWED_SPIS = ImmutableSet.>builder() + .addAll(Ydb.ALLOWED_SPIS) + .add(UserStorageProviderSpi.class) + .add(UserFederatedStorageProviderSpi.class) + .add(ClientScopeStorageProviderSpi.class) + + .build(); + + static final Set> ALLOWED_FACTORIES = ImmutableSet.>builder() + .addAll(Ydb.ALLOWED_FACTORIES) + .add(JpaUserFederatedStorageProviderFactory.class) + .add(ClientScopeStorageProviderFactory.class) + .build(); + + public YdbFederation() { + super(ALLOWED_SPIS, ALLOWED_FACTORIES); + } + + @Override + public Stream getParameters(Class clazz) { + if (ClientScopeStorageProviderModel.class.isAssignableFrom(clazz)) { + ClientScopeStorageProviderModel federatedStorage = new ClientScopeStorageProviderModel(); + federatedStorage.setName(HardcodedClientScopeStorageProviderFactory.PROVIDER_ID + ":" + counter.getAndIncrement()); + federatedStorage.setProviderId(HardcodedClientScopeStorageProviderFactory.PROVIDER_ID); + federatedStorage.setProviderType(ClientScopeStorageProvider.class.getName()); + return Stream.of((T) federatedStorage); + } else { + return super.getParameters(clazz); + } + } + + @Override + public void updateConfig(Config cf) { + } +} diff --git a/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory b/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory new file mode 100644 index 00000000..5134756f --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.testsuite.clientscope.HardcodedClientScopeStorageProviderFactory \ No newline at end of file From 2a2f84911e25dbeb04bac791b1dfd48fd3c69f0a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 6 Mar 2026 00:28:14 +0300 Subject: [PATCH 028/120] test: Add AdminEventQueryTest --- ...-00-21-create-admin-event-entity-table.sql | 23 +++ .../resources/ydb/db.changelog-master.xml | 3 + .../testsuite/events/AdminEventQueryTest.java | 190 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/AdminEventQueryTest.java 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..e14ca3f1 --- /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,23 @@ +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), + INDEX idx_admin_event_realm GLOBAL ON (REALM_ID), + INDEX idx_admin_event_user GLOBAL ON (AUTH_USER_ID), + INDEX idx_admin_event_client GLOBAL ON (AUTH_CLIENT_ID), + INDEX idx_admin_event_operation GLOBAL ON (OPERATION_TYPE), + PRIMARY KEY (ID) +); \ No newline at end of file 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 index c3e4f154..35b45f02 100644 --- 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 @@ -247,4 +247,7 @@ + + + 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"; + } + }; + } + } + +} From f932c782ebfaca368c180cda19ebaeb4653af000 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 6 Mar 2026 00:29:58 +0300 Subject: [PATCH 029/120] test: Add EventQueryTest --- .../keycloak/testsuite/KeycloakModelTest.java | 10 + .../testsuite/events/EventQueryTest.java | 186 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/EventQueryTest.java 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 index f05bcd5a..8c2dc8c9 100644 --- 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 @@ -433,6 +433,16 @@ 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); } 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; + } + } + +} From 539922a52c8504e2b6b409631035deee2fca93f6 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 8 Mar 2026 00:17:08 +0300 Subject: [PATCH 030/120] feat: Use junit 4 as it is in keycloak --- keycloak-ydb-extension/test/pom.xml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index f7803198..7cc91fa9 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -100,14 +100,9 @@ 1.20.6 test - - org.junit.jupiter - junit-jupiter - test - tech.ydb.test - ydb-junit5-support + ydb-junit4-support 2.3.27 test From ed6ec4644c113f42982bb91f6f1f70d48cef6992 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Mar 2026 02:45:54 +0300 Subject: [PATCH 031/120] test: Add StorageTransactionTest --- .../transaction/StorageTransactionTest.java | 100 +++++++++++++++ .../testsuite/util/TransactionController.java | 115 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/transaction/StorageTransactionTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/util/TransactionController.java 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/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(); + } + } +} From e9e5ad7320fee0c4d94922d423b2a2d3f60a79cc Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Mar 2026 16:44:53 +0300 Subject: [PATCH 032/120] test: Add SingleUseObjectModelTest.java --- .../tech/ydb/keycloak/testsuite/Config.java | 9 +- .../keycloak/testsuite/KeycloakModelTest.java | 37 ++- .../SingleUseObjectModelTest.java | 293 ++++++++++++++++++ 3 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/singleUseObject/SingleUseObjectModelTest.java 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 index ac892b7c..6d949e4c 100644 --- 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 @@ -101,12 +101,17 @@ public MapConfigScope(String 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); + v = System.getProperty("keycloak." + fullKey, defaultValue); } - return v != null && !v.isEmpty() ? v : null; + return v != null && !v.isEmpty() ? v : defaultValue; } @Override 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 index 8c2dc8c9..4246f8e2 100644 --- 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 @@ -1,6 +1,5 @@ package tech.ydb.keycloak.testsuite; -import com.google.common.collect.ImmutableSet; import org.hamcrest.Matchers; import org.jboss.logging.Logger; import org.junit.*; @@ -49,14 +48,13 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.function.Function; +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(); @@ -515,4 +513,33 @@ protected void setTimeOffset(int seconds) { 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/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); + }); + } +} From e3d07acb22eac2b803bcc8f66b34f18b0ef44d50 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 20 Mar 2026 02:07:17 +0300 Subject: [PATCH 033/120] fix: Use new bootstrap-admin in conf to fix admin login --- keycloak-ydb-extension/docker/conf/keycloak.conf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index bf19fde4..603f5e3a 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -13,6 +13,6 @@ spi-connections-jpa-ydb-show-sql=true spi-connections-jpa-quarkus-enabled=false spi-connections-liquibase-quarkus-enabled=false -# --- Dev mode admin (optional; for start-dev) --- -keycloak-admin=admin -keycloak-admin-password=admin +# --- Bootstrap admin (Keycloak 26.x) --- +bootstrap-admin-username=admin +bootstrap-admin-password=admin From 1a4971d70a258e8b595771dcebc7d0e3818ff7f5 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 11 Mar 2026 21:17:39 +0300 Subject: [PATCH 034/120] feat: Handle parallel liquibase migrations, add DBLockTest --- .../YdbConnectionProviderFactoryImpl.kt | 13 +- .../keycloak/liquibase/YdbDBLockProvider.kt | 188 +++++++++++ .../liquibase/YdbDBLockProviderFactory.kt | 56 ++++ .../ydb/keycloak/liquibase/YdbLockService.kt | 244 ++++++++++++++ .../keycloak/liquibase/YdbLockSqlGenerator.kt | 63 ++++ .../keycloak/liquibase/YdbLockStatement.kt | 5 + .../liquibase/YdbUnlockSqlGenerator.kt | 54 +++ .../keycloak/liquibase/YdbUnlockStatement.kt | 5 + .../liquibase.sqlgenerator.SqlGenerator | 2 + ...ycloak.models.dblock.DBLockProviderFactory | 1 + .../ydb/keycloak/testsuite/DBLockTest.java | 312 ++++++++++++++++++ .../keycloak/testsuite/parameters/Ydb.java | 13 +- 12 files changed, 943 insertions(+), 13 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt create mode 100644 keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator create mode 100644 keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/DBLockTest.java 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 index f6b4f826..0b444e3d 100644 --- 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 @@ -41,6 +41,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf private lateinit var config: Config.Scope private var jtaEnabled by Delegates.notNull() + private lateinit var jdbcUrl: String @Volatile private lateinit var entityManagerFactory: EntityManagerFactory @@ -63,6 +64,9 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf override fun init(scope: Config.Scope) { config = scope + jdbcUrl = requireNotNull(config["jdbcUrl"]) { + "YDB JDBC URL is required" + } } override fun postInit(factory: KeycloakSessionFactory) { @@ -79,10 +83,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) } } - private fun resolveJdbcUrl(): String = requireNotNull(config["jdbcUrl"]) { - "YDB JDBC URL is required" - } - private fun createOrUpdateSchema( schema: String?, connection: Connection, @@ -150,10 +150,9 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf override fun getConnection(): Connection { try { - val url = resolveJdbcUrl() val driver = YdbDriver::class.java.name Class.forName(driver) - return DriverManager.getConnection(url) + return DriverManager.getConnection(jdbcUrl) } catch (e: Exception) { throw RuntimeException("Failed to connect to database", e) } @@ -205,7 +204,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf private fun buildPropertiesFromScope(): MutableMap { val properties = mutableMapOf() - properties[AvailableSettings.JAKARTA_JDBC_URL] = resolveJdbcUrl() + properties[AvailableSettings.JAKARTA_JDBC_URL] = jdbcUrl properties[AvailableSettings.JAKARTA_JDBC_DRIVER] = YdbDriver::class.java.name getSchema()?.let { properties[JpaUtils.HIBERNATE_DEFAULT_SCHEMA] = it } 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..55fa2c93 --- /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 lockWaitTime: $lockWaitTimeoutMillis seconds") + } + + 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/YdbLockService.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt new file mode 100644 index 00000000..947f454a --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt @@ -0,0 +1,244 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.Scope +import liquibase.exception.DatabaseException +import liquibase.exception.UnexpectedLiquibaseException +import liquibase.executor.ExecutorService +import liquibase.lockservice.StandardLockService +import liquibase.statement.SqlStatement +import liquibase.statement.core.CreateDatabaseChangeLogLockTableStatement +import liquibase.statement.core.LockDatabaseChangeLogStatement +import liquibase.statement.core.RawSqlStatement +import liquibase.util.NetUtil +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.CustomInitializeDatabaseChangeLogLockTableStatement +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(CreateDatabaseChangeLogLockTableStatement()) + 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 with name: ${escapeTableName()}") + + try { + val field = Reflections.findDeclaredField(StandardLockService::class.java, "hasDatabaseChangeLogLockTable") + Reflections.setAccessible(field) + field.set(this@YdbLockService, true) + } catch (iae: IllegalAccessException) { + throw RuntimeException(iae) + } + } + + try { + val currentIds = currentIdsInDatabaseChangeLogLockTable() + val customNamespaceIds = + DBLockProvider.Namespace.entries.map { it.getId() }.toSet() + if (!currentIds.containsAll(customNamespaceIds)) { + log.trace("Initialize Database Lock Table, current locks $currentIds") + executor.execute(CustomInitializeDatabaseChangeLogLockTableStatement(currentIds)) + database.commit() + + log.debug("Initialized record in the database lock table") + } + } catch (de: DatabaseException) { + log.warn( + "Failed to insert first record to the lock table. Maybe other transaction inserted in the meantime. Retrying...", + de + ) + log.trace(de.message, de) + database.rollback() + throw LockRetryException(de) + } + } + + private fun currentIdsInDatabaseChangeLogLockTable(): Set = try { + val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java) + .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database) + val sqlStatement: SqlStatement = RawSqlStatement("SELECT ${escapeIdColumnName()} FROM ${escapeTableName()}") + + executor.queryForList(sqlStatement) + .map { columnMap -> (columnMap["ID"] as Number).toInt() } + .toSet() + .also { database.commit() } + } catch (ulie: UnexpectedLiquibaseException) { + throw ulie.cause?.takeIf { it is DatabaseException } ?: ulie + } + + 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 + val lockedBy = "${NetUtil.getLocalHostName()} (${NetUtil.getLocalHostAddress()}):${Thread.currentThread().threadId()}" + + return try { + log.debug("Trying to acquire lock id=$id") + val affected = executor.update(YdbLockStatement(id, lockedBy)) + + if (affected > 0) { + database.commit() + + val actualLockedBy = executor + .queryForList(RawSqlStatement("SELECT LOCKEDBY FROM ${escapeTableName()} WHERE ID = $id AND LOCKED = true")) + .also { database.commit() } + .firstOrNull() + ?.get("LOCKEDBY") as? String + + if (actualLockedBy == lockedBy) { + hasChangeLogLock = true + database.setCanCacheLiquibaseTableInfo(true) + log.debug("Successfully acquired lock id=$id") + true + } else { + log.debug("Lock id=$id verification failed (lockedBy in DB: $actualLockedBy)") + false + } + } else { + database.rollback() + log.debug("Lock id=$id is held by another transaction") + false + } + } catch (de: DatabaseException) { + log.warn("Lock acquisition failed, 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 { + val affected = executor.update(YdbUnlockStatement(lockId)) + if (affected != 1) { + log.warn("Release lock id=$lockId affected $affected rows — expected exactly 1") + } + 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() + } + } + + private fun escapeIdColumnName(): String? = database.escapeColumnName( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName, + "ID" + ) + + private fun escapeTableName(): String = database.escapeTableName( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName + ) + + 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..a6e242ca --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt @@ -0,0 +1,63 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.database.Database +import liquibase.exception.ValidationErrors +import liquibase.sql.Sql +import liquibase.sql.UnparsedSql +import liquibase.sqlgenerator.SqlGeneratorChain +import liquibase.sqlgenerator.core.AbstractSqlGenerator +import tech.ydb.liquibase.database.YdbDatabase + +/** + * Generates an atomic UPDATE-based lock acquisition for YDB. + * + * Keycloak's default implementation uses SELECT FOR UPDATE to hold a pessimistic + * row-level lock for the duration of the transaction. YDB does not support + * SELECT FOR UPDATE, so instead we use a conditional UPDATE: + * + * UPDATE ... SET LOCKED = true WHERE ID = ? AND LOCKED = false + * + * If the UPDATE affects 1 row → lock acquired. + * If the UPDATE affects 0 rows → lock is held by another process, retry later. + * + * Unlike SELECT FOR UPDATE (which holds a lock implicitly until commit), + * this approach requires an explicit unlock — see YdbUnlockSqlGenerator. + */ +class YdbLockSqlGenerator : AbstractSqlGenerator() { + + override fun getPriority(): Int = PRIORITY_DATABASE + + override fun supports(statement: YdbLockStatement, database: Database): Boolean = + database is YdbDatabase + + override fun validate( + statement: YdbLockStatement, + database: Database, + chain: SqlGeneratorChain + ): ValidationErrors = ValidationErrors() + + override fun generateSql( + statement: YdbLockStatement, + database: Database, + chain: SqlGeneratorChain + ): Array { + val table = database.escapeTableName( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName + ) + val escapedLockedBy = statement.lockedBy.replace("'", "''") + val sql = + """ + UPDATE $table + SET + LOCKED = true, + LOCKGRANTED = CurrentUtcDatetime(), + LOCKEDBY = '$escapedLockedBy' + WHERE + ID = ${statement.id} AND + LOCKED = false + """.trimIndent() + return arrayOf(UnparsedSql(sql)) + } +} 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..a07dfc34 --- /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, val lockedBy: String) : 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..78a2bce6 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt @@ -0,0 +1,54 @@ +package tech.ydb.keycloak.liquibase + +import liquibase.database.Database +import liquibase.exception.ValidationErrors +import liquibase.sql.Sql +import liquibase.sql.UnparsedSql +import liquibase.sqlgenerator.SqlGeneratorChain +import liquibase.sqlgenerator.core.AbstractSqlGenerator +import tech.ydb.liquibase.database.YdbDatabase + +/** + * Generates an UPDATE-based lock release for YDB. + * + * In Keycloak's default implementation the lock is released implicitly + * by committing the transaction that holds the SELECT FOR UPDATE row lock. + * Since YDB uses an explicit LOCKED = true column instead, the lock must + * be released explicitly by setting LOCKED = false. + */ +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 table = database.escapeTableName( + database.liquibaseCatalogName, + database.liquibaseSchemaName, + database.databaseChangeLogLockTableName + ) + val sql = + """ + UPDATE $table + SET + LOCKED = false, + LOCKGRANTED = null, + LOCKEDBY = null + WHERE + ID = ${statement.id} + """.trimIndent() + return arrayOf(UnparsedSql(sql)) + } +} 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/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.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/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/parameters/Ydb.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java index 38a670eb..590bc0e7 100644 --- 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 @@ -9,9 +9,7 @@ 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; @@ -36,6 +34,8 @@ 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; @@ -83,8 +83,8 @@ public class Ydb extends KeycloakModelParameters { .add(JpaRoleProviderFactory.class) .add(JpaUpdaterProviderFactory.class) .add(JpaUserProviderFactory.class) - .add(LiquibaseConnectionProviderFactory.class) - .add(LiquibaseDBLockProviderFactory.class) + .add(YdbLiquibaseConnectionProvider.class) + .add(YdbDBLockProviderFactory.class) .add(JpaUserSessionPersisterProviderFactory.class) .add(JpaRevokedTokensPersisterProviderFactory.class) .add(MigrationProviderFactory.class) @@ -131,12 +131,13 @@ public void updateConfig(Config cf) { 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") - .spi("dblock").defaultProvider("jpa"); + .spi("deploymentState").defaultProvider("jpa"); // YDB connection - use ydb provider with jdbcUrl from YdbHelper if (ydbHelper != null) { From 2c9019860cafafc43f25dfbf28557de3f019217e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sat, 4 Apr 2026 21:40:34 +0300 Subject: [PATCH 035/120] fix: Use insert statements instead of update because UPDATE does not return affected rows --- .../ydb/keycloak/liquibase/YdbLockService.kt | 94 ++++--------------- .../keycloak/liquibase/YdbLockSqlGenerator.kt | 64 ++++++------- .../keycloak/liquibase/YdbLockStatement.kt | 2 +- .../liquibase/YdbUnlockSqlGenerator.kt | 28 ++---- 4 files changed, 58 insertions(+), 130 deletions(-) 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 index 947f454a..2300408d 100644 --- 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 @@ -2,19 +2,15 @@ package tech.ydb.keycloak.liquibase import liquibase.Scope import liquibase.exception.DatabaseException -import liquibase.exception.UnexpectedLiquibaseException import liquibase.executor.ExecutorService import liquibase.lockservice.StandardLockService -import liquibase.statement.SqlStatement import liquibase.statement.core.CreateDatabaseChangeLogLockTableStatement +import liquibase.statement.core.DeleteStatement import liquibase.statement.core.LockDatabaseChangeLogStatement -import liquibase.statement.core.RawSqlStatement -import liquibase.util.NetUtil 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.CustomInitializeDatabaseChangeLogLockTableStatement import org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockDatabaseChangeLogStatement import org.keycloak.connections.jpa.updater.liquibase.lock.LockRetryException import org.keycloak.models.dblock.DBLockProvider @@ -39,7 +35,7 @@ class YdbLockService : StandardLockServiceYdb() { throw LockRetryException(de) } - log.debug("Created database lock table with name: ${escapeTableName()}") + log.debug("Created database lock table") try { val field = Reflections.findDeclaredField(StandardLockService::class.java, "hasDatabaseChangeLogLockTable") @@ -50,41 +46,20 @@ class YdbLockService : StandardLockServiceYdb() { } } + // Clean up any LOCKED=false rows left by manual intervention. + // With INSERT/DELETE semantics, only LOCKED=true rows (actively held locks) should exist. try { - val currentIds = currentIdsInDatabaseChangeLogLockTable() - val customNamespaceIds = - DBLockProvider.Namespace.entries.map { it.getId() }.toSet() - if (!currentIds.containsAll(customNamespaceIds)) { - log.trace("Initialize Database Lock Table, current locks $currentIds") - executor.execute(CustomInitializeDatabaseChangeLogLockTableStatement(currentIds)) - database.commit() - - log.debug("Initialized record in the database lock table") - } - } catch (de: DatabaseException) { - log.warn( - "Failed to insert first record to the lock table. Maybe other transaction inserted in the meantime. Retrying...", - de + executor.execute( + DeleteStatement(database.liquibaseCatalogName, database.liquibaseSchemaName, database.databaseChangeLogLockTableName) + .setWhere("LOCKED = false") ) - log.trace(de.message, de) + database.commit() + } catch (de: DatabaseException) { database.rollback() throw LockRetryException(de) } } - private fun currentIdsInDatabaseChangeLogLockTable(): Set = try { - val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java) - .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database) - val sqlStatement: SqlStatement = RawSqlStatement("SELECT ${escapeIdColumnName()} FROM ${escapeTableName()}") - - executor.queryForList(sqlStatement) - .map { columnMap -> (columnMap["ID"] as Number).toInt() } - .toSet() - .also { database.commit() } - } catch (ulie: UnexpectedLiquibaseException) { - throw ulie.cause?.takeIf { it is DatabaseException } ?: ulie - } - override fun waitForLock() { waitForLock(LockDatabaseChangeLogStatement()) } @@ -139,37 +114,18 @@ class YdbLockService : StandardLockServiceYdb() { } val id = if (lockStmt is CustomLockDatabaseChangeLogStatement) lockStmt.id else DEFAULT_LOCK_ID - val lockedBy = "${NetUtil.getLocalHostName()} (${NetUtil.getLocalHostAddress()}):${Thread.currentThread().threadId()}" return try { log.debug("Trying to acquire lock id=$id") - val affected = executor.update(YdbLockStatement(id, lockedBy)) - - if (affected > 0) { - database.commit() + executor.execute(YdbLockStatement(id)) + database.commit() - val actualLockedBy = executor - .queryForList(RawSqlStatement("SELECT LOCKEDBY FROM ${escapeTableName()} WHERE ID = $id AND LOCKED = true")) - .also { database.commit() } - .firstOrNull() - ?.get("LOCKEDBY") as? String - - if (actualLockedBy == lockedBy) { - hasChangeLogLock = true - database.setCanCacheLiquibaseTableInfo(true) - log.debug("Successfully acquired lock id=$id") - true - } else { - log.debug("Lock id=$id verification failed (lockedBy in DB: $actualLockedBy)") - false - } - } else { - database.rollback() - log.debug("Lock id=$id is held by another transaction") - false - } + hasChangeLogLock = true + database.setCanCacheLiquibaseTableInfo(true) + log.debug("Successfully acquired lock id=$id") + true } catch (de: DatabaseException) { - log.warn("Lock acquisition failed, will retry. Details: ${de.message}") + log.debug("Lock id=$id is held by another transaction, will retry. Details: ${de.message}") try { database.rollback() } catch (_: DatabaseException) { @@ -187,10 +143,7 @@ class YdbLockService : StandardLockServiceYdb() { database.rollback() try { - val affected = executor.update(YdbUnlockStatement(lockId)) - if (affected != 1) { - log.warn("Release lock id=$lockId affected $affected rows — expected exactly 1") - } + executor.execute(YdbUnlockStatement(lockId)) database.commit() } catch (e: Exception) { throw RuntimeException("Failed to release lock id=$lockId", e) @@ -225,19 +178,6 @@ class YdbLockService : StandardLockServiceYdb() { } } - private fun escapeIdColumnName(): String? = database.escapeColumnName( - database.liquibaseCatalogName, - database.liquibaseSchemaName, - database.databaseChangeLogLockTableName, - "ID" - ) - - private fun escapeTableName(): String = database.escapeTableName( - database.liquibaseCatalogName, - database.liquibaseSchemaName, - database.databaseChangeLogLockTableName - ) - 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 index a6e242ca..d9faeb50 100644 --- 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 @@ -3,61 +3,59 @@ package tech.ydb.keycloak.liquibase import liquibase.database.Database import liquibase.exception.ValidationErrors import liquibase.sql.Sql -import liquibase.sql.UnparsedSql import liquibase.sqlgenerator.SqlGeneratorChain -import liquibase.sqlgenerator.core.AbstractSqlGenerator +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 atomic UPDATE-based lock acquisition for YDB. + * Generates an INSERT-based lock acquisition for YDB. * - * Keycloak's default implementation uses SELECT FOR UPDATE to hold a pessimistic - * row-level lock for the duration of the transaction. YDB does not support - * SELECT FOR UPDATE, so instead we use a conditional UPDATE: + * 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". * - * UPDATE ... SET LOCKED = true WHERE ID = ? AND LOCKED = false + * 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. * - * If the UPDATE affects 1 row → lock acquired. - * If the UPDATE affects 0 rows → lock is held by another process, retry later. - * - * Unlike SELECT FOR UPDATE (which holds a lock implicitly until commit), - * this approach requires an explicit unlock — see YdbUnlockSqlGenerator. + * Extends LockDatabaseChangeLogGenerator to inherit hostname/hostaddress/hostDescription + * static fields */ -class YdbLockSqlGenerator : AbstractSqlGenerator() { +class YdbLockSqlGenerator : LockDatabaseChangeLogGenerator() { override fun getPriority(): Int = PRIORITY_DATABASE - override fun supports(statement: YdbLockStatement, database: Database): Boolean = - database is YdbDatabase + override fun supports(statement: LockDatabaseChangeLogStatement, database: Database): Boolean = + statement is YdbLockStatement && database is YdbDatabase override fun validate( - statement: YdbLockStatement, + statement: LockDatabaseChangeLogStatement, database: Database, - chain: SqlGeneratorChain + chain: SqlGeneratorChain<*> ): ValidationErrors = ValidationErrors() override fun generateSql( - statement: YdbLockStatement, + statement: LockDatabaseChangeLogStatement, database: Database, - chain: SqlGeneratorChain + chain: SqlGeneratorChain<*> ): Array { - val table = database.escapeTableName( + statement as YdbLockStatement + + val insertStatement = InsertStatement( database.liquibaseCatalogName, database.liquibaseSchemaName, database.databaseChangeLogLockTableName ) - val escapedLockedBy = statement.lockedBy.replace("'", "''") - val sql = - """ - UPDATE $table - SET - LOCKED = true, - LOCKGRANTED = CurrentUtcDatetime(), - LOCKEDBY = '$escapedLockedBy' - WHERE - ID = ${statement.id} AND - LOCKED = false - """.trimIndent() - return arrayOf(UnparsedSql(sql)) + .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 index a07dfc34..a04c2cea 100644 --- 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 @@ -2,4 +2,4 @@ package tech.ydb.keycloak.liquibase import liquibase.statement.core.LockDatabaseChangeLogStatement -class YdbLockStatement(val id: Int, val lockedBy: String) : 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 index 78a2bce6..5113620e 100644 --- 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 @@ -3,18 +3,17 @@ package tech.ydb.keycloak.liquibase import liquibase.database.Database import liquibase.exception.ValidationErrors import liquibase.sql.Sql -import liquibase.sql.UnparsedSql 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 an UPDATE-based lock release for YDB. + * Generates a DELETE-based lock release for YDB. * - * In Keycloak's default implementation the lock is released implicitly - * by committing the transaction that holds the SELECT FOR UPDATE row lock. - * Since YDB uses an explicit LOCKED = true column instead, the lock must - * be released explicitly by setting LOCKED = false. + * 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() { @@ -34,21 +33,12 @@ class YdbUnlockSqlGenerator : AbstractSqlGenerator() { database: Database, chain: SqlGeneratorChain ): Array { - val table = database.escapeTableName( + val deleteStatement = DeleteStatement( database.liquibaseCatalogName, database.liquibaseSchemaName, database.databaseChangeLogLockTableName - ) - val sql = - """ - UPDATE $table - SET - LOCKED = false, - LOCKGRANTED = null, - LOCKEDBY = null - WHERE - ID = ${statement.id} - """.trimIndent() - return arrayOf(UnparsedSql(sql)) + ).setWhere("ID = ${statement.id}") + + return SqlGeneratorFactory.getInstance().generateSql(deleteStatement, database) } } From 895e373a158fd3838990508793ea5408b5d1a824 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 22 Mar 2026 23:12:04 +0300 Subject: [PATCH 036/120] feat: Add YdbRetryableException handling to respond with 503 error code with specific message --- .../YdbConnectionProviderFactoryImpl.kt | 8 ++- .../connection/YdbEntityManagerProxy.kt | 56 +++++++++++++++++++ .../connection/YdbJpaKeycloakTransaction.kt | 36 ++++++++++++ .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 13 +++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt 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 index f6b4f826..2a182443 100644 --- 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 @@ -10,7 +10,6 @@ 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.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.connections.jpa.updater.JpaUpdaterProvider import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY @@ -55,10 +54,13 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf emf.createEntityManager(SYNCHRONIZED) } + val keycloakEm = EntityManagerProxy.create(session, em, true) + val ydbEm = YdbEntityManagerProxy.create(keycloakEm) + if (!jtaEnabled) { - session.transactionManager.enlist(JpaKeycloakTransaction(em)) + session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm)) } - return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) + return DefaultJpaConnectionProvider(ydbEm) } override fun init(scope: Config.Scope) { 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..16b22c38 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt @@ -0,0 +1,56 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.jboss.logging.Logger +import tech.ydb.keycloak.utils.isYdbRetryable +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +// TODO: add unit test +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 ydbRetryableResponse(cause) + } + throw cause + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw ydbRetryableResponse(e) + } + throw e + } + } + + private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( + cause.message, + cause, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + + 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 + } + } +} \ No newline at end of file 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..325e6bd4 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt @@ -0,0 +1,36 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.jboss.logging.Logger +import org.keycloak.connections.jpa.JpaKeycloakTransaction +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 WebApplicationException( + "YDB transaction aborted due to contention", + e, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + } + 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/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt new file mode 100644 index 00000000..cde51afb --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt @@ -0,0 +1,13 @@ +package tech.ydb.keycloak.utils + +import tech.ydb.jdbc.exception.YdbRetryableException + +// TODO: add unit test +fun isYdbRetryable(t: Throwable): Boolean { + var cause: Throwable? = t + while (cause != null) { + if (cause is YdbRetryableException) return true + cause = cause.cause + } + return false +} From dce171692b9e66f2453eacc1ce235155a72a8abb Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 00:04:40 +0300 Subject: [PATCH 037/120] feat: Add hikari connection pool because default hibernate pool it not intended for production use --- keycloak-ydb-extension/README.md | 17 +++++++++++++ .../YdbConnectionProviderFactoryImpl.kt | 24 +++++++++++++++++-- .../docker/conf/keycloak.conf | 12 ++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 5f8b69f5..fdb3c925 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -17,6 +17,23 @@ When using YDB you must avoid giving the YDB URL to Keycloak’s default datasou 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). 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 index 2a182443..c7be543f 100644 --- 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 @@ -28,6 +28,8 @@ 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 com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource import java.sql.Connection import java.sql.DriverManager import java.util.* @@ -44,6 +46,8 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf @Volatile private lateinit var entityManagerFactory: EntityManagerFactory + private lateinit var dataSource: HikariDataSource + override fun create(session: KeycloakSession): JpaConnectionProvider { val emf = getOrCreateEntityManagerFactory(session) @@ -144,6 +148,9 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf if (::entityManagerFactory.isInitialized) { entityManagerFactory.close() } + if (::dataSource.isInitialized) { + dataSource.close() + } } override fun getId(): String = PROVIDER_ID @@ -207,8 +214,21 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf private fun buildPropertiesFromScope(): MutableMap { val properties = mutableMapOf() - properties[AvailableSettings.JAKARTA_JDBC_URL] = resolveJdbcUrl() - properties[AvailableSettings.JAKARTA_JDBC_DRIVER] = YdbDriver::class.java.name + val jdbcUrl = resolveJdbcUrl() + + val hikariConfig = HikariConfig().apply { + this.jdbcUrl = 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.infof("HikariCP pool created: maxSize=%d, minIdle=%d", hikariConfig.maximumPoolSize, hikariConfig.minimumIdle) + + properties[AvailableSettings.JAKARTA_NON_JTA_DATASOURCE] = dataSource getSchema()?.let { properties[JpaUtils.HIBERNATE_DEFAULT_SCHEMA] = it } diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index 603f5e3a..ff0b5d26 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -4,9 +4,17 @@ # --- 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=true +spi-connections-jpa-ydb-show-sql=false +# spi-connections-jpa-ydb-format-sql=true -# YDB Liquibase provider: index creation threshold (optional, default 300000) +# --- 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 From fa6a83b310f126e62a9c28e68f7022f80b75fc42 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:16:41 +0300 Subject: [PATCH 038/120] feat: Add retry proxy for keycloak --- .../docker/docker-compose.yml | 18 +- keycloak-ydb-extension/pom.xml | 4 + keycloak-ydb-extension/retry-proxy/Dockerfile | 10 + keycloak-ydb-extension/retry-proxy/README.md | 37 +++ keycloak-ydb-extension/retry-proxy/pom.xml | 170 +++++++++++++ .../tech/ydb/keycloak/proxy/Dependencies.kt | 14 ++ .../tech/ydb/keycloak/proxy/RetryProxy.kt | 19 ++ .../ydb/keycloak/proxy/client/ProxyClient.kt | 17 ++ .../ydb/keycloak/proxy/config/ProxyConfig.kt | 35 +++ .../proxy/controller/ProxyController.kt | 55 +++++ .../ydb/keycloak/proxy/plugins/Routing.kt | 15 ++ .../keycloak/proxy/service/ProxyService.kt | 118 +++++++++ .../ydb/keycloak/proxy/utils/HeaderUtils.kt | 19 ++ .../proxy/controller/ProxyControllerTest.kt | 127 ++++++++++ .../proxy/service/ProxyServiceTest.kt | 225 ++++++++++++++++++ .../keycloak/proxy/utils/HeaderUtilsTest.kt | 50 ++++ .../run-keycloak-with-ydb.sh | 34 ++- 17 files changed, 958 insertions(+), 9 deletions(-) create mode 100644 keycloak-ydb-extension/retry-proxy/Dockerfile create mode 100644 keycloak-ydb-extension/retry-proxy/README.md create mode 100644 keycloak-ydb-extension/retry-proxy/pom.xml create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt mode change 100644 => 100755 keycloak-ydb-extension/run-keycloak-with-ydb.sh diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 08147aba..932ce3a6 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -44,10 +44,24 @@ services: command: > -v start-dev --cache=local - ports: - - 9090:8080 networks: - keycloak depends_on: ydb: condition: service_started + + retry-proxy: + build: + context: ../retry-proxy + environment: + - TARGET_URL=http://keycloak:8080 + - MAX_RETRIES=10 + - BASE_DELAY_MS=50 + - MAX_DELAY_MS=2000 + - LISTEN_PORT=8080 + ports: + - 9090:8080 + networks: + - keycloak + depends_on: + - keycloak diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index a64450f9..e2dd96fa 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -15,6 +15,7 @@ core test + retry-proxy @@ -36,6 +37,9 @@ 7.0.2 0.9.1 + + 3.4.1 + 1.5.16 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..c047e822 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -0,0 +1,37 @@ +# 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) | + +### 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..d0fe3bc3 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/pom.xml @@ -0,0 +1,170 @@ + + + 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-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..a12643d4 --- /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(), + ) + } +} \ No newline at end of file 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..bed163f2 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt @@ -0,0 +1,55 @@ +package tech.ydb.keycloak.proxy.controller + +import io.ktor.http.* +import io.ktor.server.application.* +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 result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host) + + 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}" + + result.headers.forEach { name, values -> + if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) { + values.forEach { value -> + response.header(name, rewriteInternalUrl(name, value, originalHost)) + } + } + } + + respondBytes(result.body, result.contentType, result.status) + } + + private fun rewriteInternalUrl(name: String, value: String, originalHost: String): String { + if (isHeader(name, HttpHeaders.Location)) { + return value.replace(config.targetUrl, "http://$originalHost") + } + return value + } +} \ No newline at end of file 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..dd7b554c --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt @@ -0,0 +1,15 @@ +package tech.ydb.keycloak.proxy.plugins + +import io.ktor.server.application.* +import io.ktor.server.routing.* +import tech.ydb.keycloak.proxy.Dependencies + +fun Application.configureRouting(deps: Dependencies) { + routing { + route("{...}") { + handle { + deps.controller.handle(call) + } + } + } +} \ No newline at end of file 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..a2e38431 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt @@ -0,0 +1,118 @@ +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 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.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, + ): 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) + } catch (e: Exception) { + return Error("Proxy error: ${e.message}") + } + + val responseBody = response.readRawBytes() + + val isRetryable = response.status.value == 503 && String(responseBody).contains("ydb_retryable") + + if (isRetryable) { + 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, + ): HttpResponse = client.request("${config.targetUrl}$path") { + this.method = method + + copyHeaders(headers) + header("X-Forwarded-Host", host) + header("X-Forwarded-Proto", "http") + header("X-Forwarded-Port", host.substringAfter(":", "80")) + + 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/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..b3b4f20a --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt @@ -0,0 +1,127 @@ +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()) } 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()) } 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()) } 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()) } returns + Success( + body = ByteArray(0), + headers = headersOf(HttpHeaders.Location, "http://backend:8080/realms/master"), + contentType = null, + status = HttpStatusCode.Found, + ) + + createClient { followRedirects = false }.get("/login").let { + assertEquals(HttpStatusCode.Found, it.status) + assertTrue(it.headers[HttpHeaders.Location]!!.contains("/realms/master")) + assertFalse(it.headers[HttpHeaders.Location]!!.contains("backend:8080")) + } + } + + @Test + fun clientDisconnectedReturnsNothing() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + ClientDisconnected + + client.get("/disconnect").let { + assertEquals("", it.bodyAsText()) + } + } +} \ No newline at end of file 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..14149a9a --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt @@ -0,0 +1,225 @@ +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", + ) + + @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 setsForwardedHeaders() = runTest { + var capturedHeaders: Headers? = null + val service = proxyService(mockClient { request -> + capturedHeaders = request.headers + respond("ok", HttpStatusCode.OK) + }) + + doRequest(service) + assertEquals("localhost:9090", capturedHeaders!!["X-Forwarded-Host"]) + assertEquals("http", capturedHeaders!!["X-Forwarded-Proto"]) + assertEquals("9090", capturedHeaders!!["X-Forwarded-Port"]) + } + + @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", + ) + + 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) + } + } +} \ No newline at end of file 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..00c0eb20 --- /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)) + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh old mode 100644 new mode 100755 index e7cd4ef9..b0052f85 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -1,17 +1,37 @@ -rm -f docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar +#!/usr/bin/env bash +set -euo pipefail -mvn -f core/pom.xml clean package +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" -mkdir -p docker/providers +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: File $JAR_FILE not found!" - echo "The project build may have failed." + 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 -docker-compose -f docker/docker-compose.yml up -d \ No newline at end of file +echo "" +echo "Stack is starting. Wait ~30-60s for Keycloak to initialize." +echo "" +echo " Keycloak (via retry-proxy): http://localhost:9090" +echo " Keycloak (direct): http://localhost:8080" +echo " YDB Monitoring: http://localhost:8765" +echo " Admin credentials: admin / admin" +echo "" +echo "Check logs: docker compose -f docker/docker-compose.yml logs -f keycloak" From 69824a89772bf5690454ca3ab2dc5102d19d837f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:43:00 +0300 Subject: [PATCH 039/120] feat: Use Forwarded header instead of X-Forwarded https://medium.com/@lm.moreira/a-complete-microservice-edge-ecosystem-with-ngix-oauth2-proxy-keycloak-and-spring-boot-part-1-441104e5f96b --- .../ydb/keycloak/proxy/controller/ProxyController.kt | 4 +++- .../tech/ydb/keycloak/proxy/service/ProxyService.kt | 11 +++++++---- .../keycloak/proxy/controller/ProxyControllerTest.kt | 10 +++++----- .../ydb/keycloak/proxy/service/ProxyServiceTest.kt | 10 ++++++---- 4 files changed, 21 insertions(+), 14 deletions(-) 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 index bed163f2..e3ccaa3d 100644 --- 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 @@ -20,8 +20,10 @@ class ProxyController( val body = call.receive() val contentType = call.request.contentType() val host = call.request.headers["Host"] ?: call.request.host() + val remoteHost = call.request.local.remoteHost + val scheme = call.request.local.scheme - val result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host) + val result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host, remoteHost, scheme) when (result) { is Success -> call.handleSuccess(result) 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 index a2e38431..4de789d9 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -28,6 +29,8 @@ class ProxyService( contentType: ContentType, headers: Headers, host: String, + remoteHost: String, + scheme: String, ): ProxyResult { for (attempt in 0..config.maxRetries) { if (!coroutineContext.isActive) { @@ -36,7 +39,7 @@ class ProxyService( } val response = try { - forwardToTarget(method, path, body, contentType, headers, host) + forwardToTarget(method, path, body, contentType, headers, host, remoteHost, scheme) } catch (e: Exception) { return Error("Proxy error: ${e.message}") } @@ -76,13 +79,13 @@ class ProxyService( contentType: ContentType, headers: Headers, host: String, + remoteHost: String, + scheme: String, ): HttpResponse = client.request("${config.targetUrl}$path") { this.method = method copyHeaders(headers) - header("X-Forwarded-Host", host) - header("X-Forwarded-Proto", "http") - header("X-Forwarded-Port", host.substringAfter(":", "80")) + header(Forwarded, "for=$remoteHost;host=$host;proto=$scheme") setBody(OutgoingByteArrayContent(body, contentType)) } 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 index b3b4f20a..085e59d2 100644 --- 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 @@ -49,7 +49,7 @@ class ProxyControllerTest { @Test fun forwardsSuccessResponse() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Success( body = "hello".toByteArray(), headers = headersOf(), @@ -65,7 +65,7 @@ class ProxyControllerTest { @Test fun returnsErrorAsBadGateway() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Error("connection refused") client.get("/fail").let { @@ -76,7 +76,7 @@ class ProxyControllerTest { @Test fun filtersHopByHopHeaders() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Success( body = "ok".toByteArray(), headers = headersOf( @@ -100,7 +100,7 @@ class ProxyControllerTest { @Test fun rewritesLocationHeader() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + 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"), @@ -117,7 +117,7 @@ class ProxyControllerTest { @Test fun clientDisconnectedReturnsNothing() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns ClientDisconnected client.get("/disconnect").let { 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 index 14149a9a..2e285d82 100644 --- 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 @@ -38,6 +38,8 @@ class ProxyServiceTest { contentType = ContentType.Application.Json, headers = headersOf(), host = "localhost:9090", + remoteHost = "127.0.0.1", + scheme = "http", ) @Test @@ -163,7 +165,7 @@ class ProxyServiceTest { } @Test - fun setsForwardedHeaders() = runTest { + fun setsForwardedHeader() = runTest { var capturedHeaders: Headers? = null val service = proxyService(mockClient { request -> capturedHeaders = request.headers @@ -171,9 +173,7 @@ class ProxyServiceTest { }) doRequest(service) - assertEquals("localhost:9090", capturedHeaders!!["X-Forwarded-Host"]) - assertEquals("http", capturedHeaders!!["X-Forwarded-Proto"]) - assertEquals("9090", capturedHeaders!!["X-Forwarded-Port"]) + assertEquals("for=127.0.0.1;host=localhost:9090;proto=http", capturedHeaders!![HttpHeaders.Forwarded]) } @Test @@ -198,6 +198,8 @@ class ProxyServiceTest { HttpHeaders.Authorization to listOf("Bearer token"), ), host = "localhost:9090", + remoteHost = "127.0.0.1", + scheme = "http", ) assertNull(capturedHeaders!![HttpHeaders.Connection]) From 4107b6e235cc84224ac0a1a906fb39e1e456370a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:43:27 +0300 Subject: [PATCH 040/120] feat: Add load testing setup for ydb --- .../docker/conf/keycloak.conf | 5 + keycloak-ydb-extension/load-test/.gitignore | 2 + keycloak-ydb-extension/load-test/README.md | 108 +++++++ .../load-test/delete-all-users.py | 92 ++++++ keycloak-ydb-extension/load-test/prepare.sh | 46 +++ keycloak-ydb-extension/load-test/run.sh | 72 +++++ .../load-test/setup-test-realm.py | 280 ++++++++++++++++++ 7 files changed, 605 insertions(+) create mode 100644 keycloak-ydb-extension/load-test/.gitignore create mode 100644 keycloak-ydb-extension/load-test/README.md create mode 100644 keycloak-ydb-extension/load-test/delete-all-users.py create mode 100755 keycloak-ydb-extension/load-test/prepare.sh create mode 100755 keycloak-ydb-extension/load-test/run.sh create mode 100644 keycloak-ydb-extension/load-test/setup-test-realm.py diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index ff0b5d26..96388166 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -24,3 +24,8 @@ 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/load-test/.gitignore b/keycloak-ydb-extension/load-test/.gitignore new file mode 100644 index 00000000..219dec50 --- /dev/null +++ b/keycloak-ydb-extension/load-test/.gitignore @@ -0,0 +1,2 @@ +lib/ +results/ \ No newline at end of file diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md new file mode 100644 index 00000000..9a1909fc --- /dev/null +++ b/keycloak-ydb-extension/load-test/README.md @@ -0,0 +1,108 @@ +# Load Testing + +Load tests for Keycloak + YDB using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). + +## 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. Build and start infrastructure + +From the `keycloak-ydb-extension/` root: + +```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 keycloak +``` + +Services: +- Keycloak (via retry-proxy): http://localhost:9090 +- YDB Monitoring: http://localhost:8765 +- Admin credentials: `admin` / `admin` + +### 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. + +By default connects to `http://localhost:9090`. To use a different URL: + +```bash +python3 setup-test-realm.py http://localhost:8080 +``` + +### 4. Run load test + +```bash +./run.sh [measurement-sec] [server-url] +``` + +Examples: + +```bash +./run.sh CreateUsers 30 # 30 rps, 60s measurement +./run.sh CreateUsers 30 120 # 30 rps, 120s measurement +./run.sh CreateDeleteUsers 10 60 # 10 rps, 60s +``` + +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 | Description | +|----------|-------------| +| `CreateUsers` | Create user + List users | +| `CreateDeleteUsers` | Create user + List users + Delete user | +| `CreateClients` | Create client | +| `CreateDeleteClients` | Create client + Delete client | +| `ClientSecret` | Client credentials grant (authentication) | +| `AuthorizationCode` | Authorization code flow (authentication) | + +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 + run.sh # Runs Gatling scenario + 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) +``` \ No newline at end of file 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..2b75a789 --- /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/" \ No newline at end of file diff --git a/keycloak-ydb-extension/load-test/run.sh b/keycloak-ydb-extension/load-test/run.sh new file mode 100755 index 00000000..a1249453 --- /dev/null +++ b/keycloak-ydb-extension/load-test/run.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Runs a Gatling load test scenario against Keycloak. +# +# Usage: +# ./run.sh [measurement-sec] [server-url] +# +# Examples: +# ./run.sh CreateUsers 30 +# ./run.sh CreateUsers 30 60 +# ./run.sh CreateDeleteUsers 10 60 http://localhost:9090 +# +# Available scenarios: +# CreateUsers - Create + List users +# CreateDeleteUsers - Create + List + Delete users +# ClientSecret - Client credentials grant +# AuthorizationCode - Authorization code flow +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" +RESULTS_DIR="$SCRIPT_DIR/results" + +if [ ! -d "$LIB_DIR" ] || [ -z "$(ls -A "$LIB_DIR" 2>/dev/null)" ]; then + echo "ERROR: No JARs found in $LIB_DIR" + echo "Run ./prepare.sh first" + exit 1 +fi + +SCENARIO="${1:?Usage: ./run.sh [measurement-sec] [server-url]}" +USERS_PER_SEC="${2:?Usage: ./run.sh [measurement-sec] [server-url]}" +MEASUREMENT="${3:-60}" +SERVER_URL="${4:-http://localhost:9090}" +REALM="${5:-test-realm}" + +# Resolve full scenario class name +case "$SCENARIO" in + CreateUsers|CreateDeleteUsers|CreateClients|CreateDeleteClients|CreateRealms) + SCENARIO_CLASS="keycloak.scenario.admin.$SCENARIO" + ;; + ClientSecret|AuthorizationCode) + SCENARIO_CLASS="keycloak.scenario.authentication.$SCENARIO" + ;; + *) + SCENARIO_CLASS="$SCENARIO" + ;; +esac + +CLASSPATH=$(find "$LIB_DIR" -type f -name '*.jar' | tr '\n' ':') + +echo "============================================" +echo " Scenario: $SCENARIO_CLASS" +echo " Users/sec: $USERS_PER_SEC" +echo " Measurement: ${MEASUREMENT}s" +echo " Server: $SERVER_URL" +echo " Realm: $REALM" +echo "============================================" +echo + +java -server -Xmx1G \ + -Dserver-url="$SERVER_URL" \ + -Drealm-name="$REALM" \ + -Dclient-id=gatling \ + -Dclient-secret=setup-for-benchmark \ + -Dusers-per-sec="$USERS_PER_SEC" \ + -Dmeasurement="$MEASUREMENT" \ + -cp "$CLASSPATH" \ + io.gatling.app.Gatling \ + -rf "$RESULTS_DIR" \ + -s "$SCENARIO_CLASS" \ No newline at end of file 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 From e11b631a1935a0712a8b3e60a0c2aad0e57c7d8c Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:53:43 +0300 Subject: [PATCH 041/120] refactor: make different names to services to easily differ them --- keycloak-ydb-extension/docker/docker-compose.yml | 7 ++++--- keycloak-ydb-extension/load-test/README.md | 2 +- keycloak-ydb-extension/run-keycloak-with-ydb.sh | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 932ce3a6..7e8119a2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -35,8 +35,9 @@ services: timeout: 5s retries: 5 - keycloak: + ydb-keycloak: image: quay.io/keycloak/keycloak:26.4.7 + container_name: ydb-keycloak 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 @@ -54,7 +55,7 @@ services: build: context: ../retry-proxy environment: - - TARGET_URL=http://keycloak:8080 + - TARGET_URL=http://ydb-keycloak:8080 - MAX_RETRIES=10 - BASE_DELAY_MS=50 - MAX_DELAY_MS=2000 @@ -64,4 +65,4 @@ services: networks: - keycloak depends_on: - - keycloak + - ydb-keycloak diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 9a1909fc..a8f6d261 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -36,7 +36,7 @@ This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Wait for Keycloak to start (~30-60s). Check logs: ```bash -docker compose -f docker/docker-compose.yml logs -f keycloak +docker compose -f docker/docker-compose.yml logs -f ydb-keycloak ``` Services: diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index b0052f85..c9ef6de3 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -34,4 +34,4 @@ echo " Keycloak (direct): http://localhost:8080" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" -echo "Check logs: docker compose -f docker/docker-compose.yml logs -f keycloak" +echo "Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak" From 544499a2a7846a66b7dd37dc1fd7a711a33fc644 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:54:42 +0300 Subject: [PATCH 042/120] fix: Make keycloak port not exposable outward --- keycloak-ydb-extension/docker/docker-compose.yml | 2 ++ keycloak-ydb-extension/run-keycloak-with-ydb.sh | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 7e8119a2..428b3de2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -38,6 +38,8 @@ services: 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 diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index c9ef6de3..6140d57d 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -30,7 +30,6 @@ echo "" echo "Stack is starting. Wait ~30-60s for Keycloak to initialize." echo "" echo " Keycloak (via retry-proxy): http://localhost:9090" -echo " Keycloak (direct): http://localhost:8080" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" From 3c8b2e31efd8bf2487894eff8340e99dcb26a133 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:57:15 +0300 Subject: [PATCH 043/120] fix: Add keycloak postgres example for load testing --- .../docker/docker-compose-pg.yml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 keycloak-ydb-extension/docker/docker-compose-pg.yml diff --git a/keycloak-ydb-extension/docker/docker-compose-pg.yml b/keycloak-ydb-extension/docker/docker-compose-pg.yml new file mode 100644 index 00000000..57d32601 --- /dev/null +++ b/keycloak-ydb-extension/docker/docker-compose-pg.yml @@ -0,0 +1,51 @@ +version: '3.4' + +volumes: + pg_data: + driver: local + +networks: + keycloak: + driver: bridge + +services: + postgres: + image: postgres:17 + container_name: postgres + volumes: + - pg_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + ports: + - '5432:5432' + networks: + - keycloak + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak"] + interval: 5s + timeout: 3s + retries: 5 + + pg-keycloak: + image: quay.io/keycloak/keycloak:26.4.7 + container_name: pg-keycloak + entrypoint: /opt/keycloak/bin/kc.sh + command: > + -v start-dev + --cache=local + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + ports: + - '9091:8080' + networks: + - keycloak + depends_on: + postgres: + condition: service_healthy From 0d257a819cdb37cfe651582585803c49a3e65c3c Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 03:04:21 +0300 Subject: [PATCH 044/120] fix: Add remote ydb infrastructure --- .../docker/conf/keycloak-remote-ydb.conf | 28 ++++++++++++ .../docker/docker-compose-remote-ydb.yml | 40 +++++++++++++++++ keycloak-ydb-extension/load-test/README.md | 43 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf create mode 100644 keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml 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..17ab5a4a --- /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) --- +# Override YDB_JDBC_URL env var to point to your remote YDB instance, e.g.: +# grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/b1g.../etn... +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/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/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index a8f6d261..11afc750 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -82,6 +82,49 @@ Delete all users from test-realm: python3 delete-all-users.py ``` +## Alternative Infrastructure + +### Keycloak + PostgreSQL + +For comparison testing against PostgreSQL: + +```bash +docker compose -f docker/docker-compose-pg.yml up -d +``` + +Services: +- Keycloak: http://localhost:9091 +- Admin credentials: `admin` / `admin` + +### Keycloak + Remote YDB + +For connecting to an external YDB instance (not in Docker Compose): + +```bash +# Start YDB separately, e.g.: +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: +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 +``` + +Services: +- Keycloak (via retry-proxy): http://localhost:9090 +- Admin credentials: `admin` / `admin` + ## Available Scenarios | Scenario | Description | From 0dc9ecf149c2b239edb641122bf261c3e24d2e4a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 03:21:23 +0300 Subject: [PATCH 045/120] docs: Update load testing readme --- keycloak-ydb-extension/load-test/README.md | 114 +++++++++++---------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 11afc750..30d489c8 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -1,6 +1,11 @@ # Load Testing -Load tests for Keycloak + YDB using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). +Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). + +Supports three 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 +- **Keycloak + PostgreSQL** — for comparison benchmarks ## Prerequisites @@ -10,7 +15,7 @@ Load tests for Keycloak + YDB using [keycloak-benchmark](https://github.com/keyc ## Quick Start -### 1. Download keycloak-benchmark +## 1. Download keycloak-benchmark ```bash ./prepare.sh @@ -23,9 +28,11 @@ To use a specific version: ./prepare.sh 26.4.0-SNAPSHOT ``` -### 2. Build and start infrastructure +## 2. Start infrastructure + +All commands below are run from the `keycloak-ydb-extension/` root. -From the `keycloak-ydb-extension/` root: +### Option A: Keycloak + Local YDB ```bash ./run-keycloak-with-ydb.sh @@ -39,92 +46,91 @@ Wait for Keycloak to start (~30-60s). Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak ``` -Services: -- Keycloak (via retry-proxy): http://localhost:9090 -- YDB Monitoring: http://localhost:8765 -- Admin credentials: `admin` / `admin` +| Service | URL | +|---------|-----| +| Keycloak (via retry-proxy) | http://localhost:9090 | +| YDB Monitoring | http://localhost:8765 | + +### Option B: Keycloak + Remote YDB -### 3. Setup test realm +Start YDB separately, e.g.: ```bash -python3 setup-test-realm.py +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 ``` -Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. - -By default connects to `http://localhost:9090`. To use a different URL: +Then start Keycloak + retry-proxy: ```bash -python3 setup-test-realm.py http://localhost:8080 +YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ + docker compose -f docker/docker-compose-remote-ydb.yml up -d --build ``` -### 4. Run load test +For a cloud YDB instance: ```bash -./run.sh [measurement-sec] [server-url] +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 ``` -Examples: +| Service | URL | +|---------|-----| +| Keycloak (via retry-proxy) | http://localhost:9090 | + +### Option C: Keycloak + PostgreSQL ```bash -./run.sh CreateUsers 30 # 30 rps, 60s measurement -./run.sh CreateUsers 30 120 # 30 rps, 120s measurement -./run.sh CreateDeleteUsers 10 60 # 10 rps, 60s +docker compose -f docker/docker-compose-pg.yml up -d ``` -Results are saved to `results/` with Gatling HTML reports. +| Service | URL | +|---------|-----| +| Keycloak | http://localhost:9091 | -### 5. Cleanup between runs +Admin credentials for all options: `admin` / `admin` -Delete all users from test-realm: +## 3. Setup test realm ```bash -python3 delete-all-users.py +python3 setup-test-realm.py # default: http://localhost:9090 +python3 setup-test-realm.py http://localhost:9091 # for PostgreSQL setup ``` -## Alternative Infrastructure - -### Keycloak + PostgreSQL +Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. -For comparison testing against PostgreSQL: +## 4. Run load test ```bash -docker compose -f docker/docker-compose-pg.yml up -d +./run.sh [measurement-sec] [server-url] ``` -Services: -- Keycloak: http://localhost:9091 -- Admin credentials: `admin` / `admin` - -### Keycloak + Remote YDB - -For connecting to an external YDB instance (not in Docker Compose): +Examples: ```bash -# Start YDB separately, e.g.: -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 +# Local YDB / Remote YDB (default server-url is http://localhost:9090) +./run.sh CreateUsers 30 +./run.sh CreateUsers 30 120 -# Then start Keycloak + retry-proxy: -YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ - docker compose -f docker/docker-compose-remote-ydb.yml up -d --build +# PostgreSQL +./run.sh CreateUsers 30 60 http://localhost:9091 ``` -For a cloud YDB instance: +Results are saved to `results/` with Gatling HTML reports. + +## 5. Cleanup between runs + +Delete all users from test-realm: ```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 +python3 delete-all-users.py # default: http://localhost:9090 +python3 delete-all-users.py http://localhost:9091 # for PostgreSQL setup ``` -Services: -- Keycloak (via retry-proxy): http://localhost:9090 -- Admin credentials: `admin` / `admin` - ## Available Scenarios | Scenario | Description | @@ -148,4 +154,4 @@ load-test/ delete-all-users.py # Deletes all users from realm lib/ # Benchmark JARs (gitignored) results/ # Gatling reports (gitignored) -``` \ No newline at end of file +``` From 3577a09dcf97f08b745ce395d34a6c04df189767 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 03:29:31 +0300 Subject: [PATCH 046/120] refactor: Optimize imports, add new line at file ends --- .../YdbConnectionProviderFactoryImpl.kt | 6 +- .../connection/YdbEntityManagerProxy.kt | 74 +++++++++---------- .../connection/YdbJpaKeycloakTransaction.kt | 42 +++++------ .../keycloak/realm/YdbRealmProviderFactory.kt | 2 +- .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 12 +-- .../docker/docker-compose-pg.yml | 2 +- .../docker/docker-compose.yml | 2 +- keycloak-ydb-extension/load-test/.gitignore | 2 +- keycloak-ydb-extension/load-test/README.md | 36 ++++----- keycloak-ydb-extension/load-test/prepare.sh | 2 +- keycloak-ydb-extension/load-test/run.sh | 2 +- keycloak-ydb-extension/retry-proxy/README.md | 29 ++++---- .../ydb/keycloak/proxy/config/ProxyConfig.kt | 2 +- .../proxy/controller/ProxyController.kt | 13 +++- .../ydb/keycloak/proxy/plugins/Routing.kt | 2 +- .../proxy/controller/ProxyControllerTest.kt | 2 +- .../proxy/service/ProxyServiceTest.kt | 2 +- .../keycloak/proxy/utils/HeaderUtilsTest.kt | 2 +- 18 files changed, 124 insertions(+), 110 deletions(-) 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 index c7be543f..1350869e 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -28,8 +30,6 @@ 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 com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource import java.sql.Connection import java.sql.DriverManager import java.util.* @@ -226,7 +226,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf maxLifetime = config.getLong("maxLifetime", 1800000) } dataSource = HikariDataSource(hikariConfig) - logger.infof("HikariCP pool created: maxSize=%d, minIdle=%d", hikariConfig.maximumPoolSize, hikariConfig.minimumIdle) + logger.info("HikariCP pool created: maxSize=${hikariConfig.maximumPoolSize}, minIdle=${hikariConfig.minimumIdle}") properties[AvailableSettings.JAKARTA_NON_JTA_DATASOURCE] = dataSource 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 index 16b22c38..e1f7215f 100644 --- 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 @@ -12,45 +12,45 @@ import java.lang.reflect.Proxy // TODO: add unit test 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 ydbRetryableResponse(cause) - } - throw cause - } catch (e: Exception) { - if (isYdbRetryable(e)) { - LOG.warn("YDB retryable error during ${method.name}, returning 503") - throw ydbRetryableResponse(e) - } - throw e - } + 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 ydbRetryableResponse(cause) + } + throw cause + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw ydbRetryableResponse(e) + } + throw e } + } - private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( - cause.message, - cause, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) + private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( + cause.message, + cause, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) - companion object { - private val LOG: Logger = Logger.getLogger(YdbEntityManagerProxy::class.java) + 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 - } + fun create(em: EntityManager): EntityManager { + val proxy = YdbEntityManagerProxy(em) + return Proxy.newProxyInstance( + EntityManager::class.java.classLoader, + arrayOf(EntityManager::class.java), + proxy::invoke + ) as EntityManager } -} \ No newline at end of file + } +} 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 index 325e6bd4..202e1106 100644 --- 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 @@ -10,27 +10,27 @@ 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 WebApplicationException( - "YDB transaction aborted due to contention", - e, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - } - throw e - } + override fun commit() { + try { + super.commit() + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during commit, returning 503") + throw WebApplicationException( + "YDB transaction aborted due to contention", + e, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + } + throw e } + } - companion object { - private val LOG: Logger = Logger.getLogger(YdbJpaKeycloakTransaction::class.java) - } + companion object { + private val LOG: Logger = Logger.getLogger(YdbJpaKeycloakTransaction::class.java) + } } 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 index 5ab4f1c5..1e7fe163 100644 --- 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 @@ -2,10 +2,10 @@ 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 org.keycloak.connections.jpa.JpaConnectionProvider import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID import tech.ydb.keycloak.config.ProviderConfig.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 index cde51afb..7e797251 100644 --- 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 @@ -4,10 +4,10 @@ import tech.ydb.jdbc.exception.YdbRetryableException // TODO: add unit test fun isYdbRetryable(t: Throwable): Boolean { - var cause: Throwable? = t - while (cause != null) { - if (cause is YdbRetryableException) return true - cause = cause.cause - } - return false + var cause: Throwable? = t + while (cause != null) { + if (cause is YdbRetryableException) return true + cause = cause.cause + } + return false } diff --git a/keycloak-ydb-extension/docker/docker-compose-pg.yml b/keycloak-ydb-extension/docker/docker-compose-pg.yml index 57d32601..1f003f59 100644 --- a/keycloak-ydb-extension/docker/docker-compose-pg.yml +++ b/keycloak-ydb-extension/docker/docker-compose-pg.yml @@ -23,7 +23,7 @@ services: networks: - keycloak healthcheck: - test: ["CMD-SHELL", "pg_isready -U keycloak"] + test: [ "CMD-SHELL", "pg_isready -U keycloak" ] interval: 5s timeout: 3s retries: 5 diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 428b3de2..f05d19c2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -30,7 +30,7 @@ services: - MON_PORT=8765 - YDB_KAFKA_PROXY_PORT=9092 healthcheck: - test: ["CMD", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls"] + test: [ "CMD", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls" ] interval: 10s timeout: 5s retries: 5 diff --git a/keycloak-ydb-extension/load-test/.gitignore b/keycloak-ydb-extension/load-test/.gitignore index 219dec50..4ca468ac 100644 --- a/keycloak-ydb-extension/load-test/.gitignore +++ b/keycloak-ydb-extension/load-test/.gitignore @@ -1,2 +1,2 @@ lib/ -results/ \ No newline at end of file +results/ diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 30d489c8..d44c3efb 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -3,6 +3,7 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). Supports three 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 - **Keycloak + PostgreSQL** — for comparison benchmarks @@ -46,10 +47,10 @@ Wait for Keycloak to start (~30-60s). Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak ``` -| Service | URL | -|---------|-----| +| Service | URL | +|----------------------------|-----------------------| | Keycloak (via retry-proxy) | http://localhost:9090 | -| YDB Monitoring | http://localhost:8765 | +| YDB Monitoring | http://localhost:8765 | ### Option B: Keycloak + Remote YDB @@ -78,8 +79,8 @@ 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 | -|---------|-----| +| Service | URL | +|----------------------------|-----------------------| | Keycloak (via retry-proxy) | http://localhost:9090 | ### Option C: Keycloak + PostgreSQL @@ -88,8 +89,8 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. docker compose -f docker/docker-compose-pg.yml up -d ``` -| Service | URL | -|---------|-----| +| Service | URL | +|----------|-----------------------| | Keycloak | http://localhost:9091 | Admin credentials for all options: `admin` / `admin` @@ -133,16 +134,17 @@ python3 delete-all-users.py http://localhost:9091 # for PostgreSQL setup ## Available Scenarios -| Scenario | Description | -|----------|-------------| -| `CreateUsers` | Create user + List users | -| `CreateDeleteUsers` | Create user + List users + Delete user | -| `CreateClients` | Create client | -| `CreateDeleteClients` | Create client + Delete client | -| `ClientSecret` | Client credentials grant (authentication) | -| `AuthorizationCode` | Authorization code flow (authentication) | - -Full list of scenarios: [keycloak-benchmark/scenario](https://github.com/keycloak/keycloak-benchmark/tree/main/benchmark/src/main/scala/keycloak/scenario) +| Scenario | Description | +|-----------------------|-------------------------------------------| +| `CreateUsers` | Create user + List users | +| `CreateDeleteUsers` | Create user + List users + Delete user | +| `CreateClients` | Create client | +| `CreateDeleteClients` | Create client + Delete client | +| `ClientSecret` | Client credentials grant (authentication) | +| `AuthorizationCode` | Authorization code flow (authentication) | + +Full list of scenarios: +[keycloak-benchmark/scenario](https://github.com/keycloak/keycloak-benchmark/tree/main/benchmark/src/main/scala/keycloak/scenario) ## Directory Structure diff --git a/keycloak-ydb-extension/load-test/prepare.sh b/keycloak-ydb-extension/load-test/prepare.sh index 2b75a789..9df70063 100755 --- a/keycloak-ydb-extension/load-test/prepare.sh +++ b/keycloak-ydb-extension/load-test/prepare.sh @@ -43,4 +43,4 @@ cp "$DIST_DIR/lib/"*.jar "$LIB_DIR/" echo echo "Done. JARs in $LIB_DIR/:" -ls -lh "$LIB_DIR/" \ No newline at end of file +ls -lh "$LIB_DIR/" diff --git a/keycloak-ydb-extension/load-test/run.sh b/keycloak-ydb-extension/load-test/run.sh index a1249453..fc266603 100755 --- a/keycloak-ydb-extension/load-test/run.sh +++ b/keycloak-ydb-extension/load-test/run.sh @@ -69,4 +69,4 @@ java -server -Xmx1G \ -cp "$CLASSPATH" \ io.gatling.app.Gatling \ -rf "$RESULTS_DIR" \ - -s "$SCENARIO_CLASS" \ No newline at end of file + -s "$SCENARIO_CLASS" diff --git a/keycloak-ydb-extension/retry-proxy/README.md b/keycloak-ydb-extension/retry-proxy/README.md index c047e822..fde3d002 100644 --- a/keycloak-ydb-extension/retry-proxy/README.md +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -4,9 +4,12 @@ HTTP reverse proxy for Keycloak that automatically retries requests on YDB-speci ## 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. +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 @@ -20,18 +23,18 @@ 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) | +| 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) | ### 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) | +| 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/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 index a12643d4..e19fd3df 100644 --- 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 @@ -32,4 +32,4 @@ data class ProxyConfig( client = ClientConfig.fromEnv(), ) } -} \ No newline at end of file +} 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 index e3ccaa3d..53b5a6de 100644 --- 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 @@ -23,7 +23,16 @@ class ProxyController( val remoteHost = call.request.local.remoteHost val scheme = call.request.local.scheme - val result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host, remoteHost, 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) @@ -54,4 +63,4 @@ class ProxyController( } return value } -} \ No newline at end of file +} 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 index dd7b554c..04ea9e81 100644 --- 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 @@ -12,4 +12,4 @@ fun Application.configureRouting(deps: Dependencies) { } } } -} \ No newline at end of file +} 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 index 085e59d2..49529930 100644 --- 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 @@ -124,4 +124,4 @@ class ProxyControllerTest { assertEquals("", it.bodyAsText()) } } -} \ No newline at end of file +} 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 index 2e285d82..9552cb94 100644 --- 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 @@ -224,4 +224,4 @@ class ProxyServiceTest { assertTrue(delay in 0..config.maxDelayMs) } } -} \ No newline at end of file +} 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 index 00c0eb20..437459d2 100644 --- 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 @@ -47,4 +47,4 @@ class HeaderUtilsTest { assertFalse(isHeader("Content-Type", HttpHeaders.ContentLength)) assertFalse(isHeader("Accept", HttpHeaders.Location)) } -} \ No newline at end of file +} From d37b551d2f7de86920aafd2d367e4090b81a19cf Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sat, 4 Apr 2026 21:59:52 +0300 Subject: [PATCH 047/120] refactor: Delete postgres mention and add link to example in keycloak-benchmark repository --- .../docker/docker-compose-pg.yml | 51 ------------------- keycloak-ydb-extension/load-test/README.md | 24 +++------ 2 files changed, 6 insertions(+), 69 deletions(-) delete mode 100644 keycloak-ydb-extension/docker/docker-compose-pg.yml diff --git a/keycloak-ydb-extension/docker/docker-compose-pg.yml b/keycloak-ydb-extension/docker/docker-compose-pg.yml deleted file mode 100644 index 1f003f59..00000000 --- a/keycloak-ydb-extension/docker/docker-compose-pg.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: '3.4' - -volumes: - pg_data: - driver: local - -networks: - keycloak: - driver: bridge - -services: - postgres: - image: postgres:17 - container_name: postgres - volumes: - - pg_data:/var/lib/postgresql/data - environment: - POSTGRES_DB: keycloak - POSTGRES_USER: keycloak - POSTGRES_PASSWORD: keycloak - ports: - - '5432:5432' - networks: - - keycloak - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U keycloak" ] - interval: 5s - timeout: 3s - retries: 5 - - pg-keycloak: - image: quay.io/keycloak/keycloak:26.4.7 - container_name: pg-keycloak - entrypoint: /opt/keycloak/bin/kc.sh - command: > - -v start-dev - --cache=local - environment: - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak - KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: keycloak - KC_BOOTSTRAP_ADMIN_USERNAME: admin - KC_BOOTSTRAP_ADMIN_PASSWORD: admin - ports: - - '9091:8080' - networks: - - keycloak - depends_on: - postgres: - condition: service_healthy diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index d44c3efb..064bc112 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -2,11 +2,10 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). -Supports three infrastructure configurations: +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 -- **Keycloak + PostgreSQL** — for comparison benchmarks ## Prerequisites @@ -83,23 +82,17 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. |----------------------------|-----------------------| | Keycloak (via retry-proxy) | http://localhost:9090 | -### Option C: Keycloak + PostgreSQL - -```bash -docker compose -f docker/docker-compose-pg.yml up -d -``` +Admin credentials for all options: `admin` / `admin` -| Service | URL | -|----------|-----------------------| -| Keycloak | http://localhost:9091 | +### Comparison with other databases -Admin credentials for all options: `admin` / `admin` +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 # default: http://localhost:9090 -python3 setup-test-realm.py http://localhost:9091 # for PostgreSQL setup ``` Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. @@ -113,12 +106,8 @@ Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, Examples: ```bash -# Local YDB / Remote YDB (default server-url is http://localhost:9090) ./run.sh CreateUsers 30 ./run.sh CreateUsers 30 120 - -# PostgreSQL -./run.sh CreateUsers 30 60 http://localhost:9091 ``` Results are saved to `results/` with Gatling HTML reports. @@ -128,8 +117,7 @@ Results are saved to `results/` with Gatling HTML reports. Delete all users from test-realm: ```bash -python3 delete-all-users.py # default: http://localhost:9090 -python3 delete-all-users.py http://localhost:9091 # for PostgreSQL setup +python3 delete-all-users.py ``` ## Available Scenarios From fc49347644f21362cfe32477d05e43e0bd91af2a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sat, 4 Apr 2026 22:51:31 +0300 Subject: [PATCH 048/120] refactor: Remove run.sh and provide examples how to run scenarios. --- .../docker/conf/keycloak.conf | 2 +- keycloak-ydb-extension/load-test/README.md | 132 ++++++++++++++++-- keycloak-ydb-extension/load-test/run.sh | 72 ---------- keycloak-ydb-extension/retry-proxy/README.md | 11 ++ 4 files changed, 129 insertions(+), 88 deletions(-) delete mode 100755 keycloak-ydb-extension/load-test/run.sh diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index 96388166..c841b2f9 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -28,4 +28,4 @@ bootstrap-admin-password=admin # --- Proxy settings (retry-proxy in front) --- proxy-headers=forwarded http-enabled=true -hostname=http://localhost:9090 +hostname=http://0.0.0.0:9090 diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 064bc112..bf66e636 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -35,7 +35,7 @@ All commands below are run from the `keycloak-ydb-extension/` root. ### Option A: Keycloak + Local YDB ```bash -./run-keycloak-with-ydb.sh +../run-keycloak-with-ydb.sh ``` This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Keycloak + retry-proxy). @@ -92,24 +92,123 @@ For benchmarking Keycloak with other databases (PostgreSQL, MySQL, etc.), use th ## 3. Setup test realm ```bash -python3 setup-test-realm.py # default: http://localhost:9090 +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 -./run.sh [measurement-sec] [server-url] +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 ``` -Examples: +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 -./run.sh CreateUsers 30 -./run.sh CreateUsers 30 120 +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 @@ -122,14 +221,18 @@ python3 delete-all-users.py ## Available Scenarios -| Scenario | Description | -|-----------------------|-------------------------------------------| -| `CreateUsers` | Create user + List users | -| `CreateDeleteUsers` | Create user + List users + Delete user | -| `CreateClients` | Create client | -| `CreateDeleteClients` | Create client + Delete client | -| `ClientSecret` | Client credentials grant (authentication) | -| `AuthorizationCode` | Authorization code flow (authentication) | +| 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) @@ -139,7 +242,6 @@ Full list of scenarios: ``` load-test/ prepare.sh # Downloads keycloak-benchmark from GitHub - run.sh # Runs Gatling scenario setup-test-realm.py # Creates test realm, clients, users delete-all-users.py # Deletes all users from realm lib/ # Benchmark JARs (gitignored) diff --git a/keycloak-ydb-extension/load-test/run.sh b/keycloak-ydb-extension/load-test/run.sh deleted file mode 100755 index fc266603..00000000 --- a/keycloak-ydb-extension/load-test/run.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# -# Runs a Gatling load test scenario against Keycloak. -# -# Usage: -# ./run.sh [measurement-sec] [server-url] -# -# Examples: -# ./run.sh CreateUsers 30 -# ./run.sh CreateUsers 30 60 -# ./run.sh CreateDeleteUsers 10 60 http://localhost:9090 -# -# Available scenarios: -# CreateUsers - Create + List users -# CreateDeleteUsers - Create + List + Delete users -# ClientSecret - Client credentials grant -# AuthorizationCode - Authorization code flow -# - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -LIB_DIR="$SCRIPT_DIR/lib" -RESULTS_DIR="$SCRIPT_DIR/results" - -if [ ! -d "$LIB_DIR" ] || [ -z "$(ls -A "$LIB_DIR" 2>/dev/null)" ]; then - echo "ERROR: No JARs found in $LIB_DIR" - echo "Run ./prepare.sh first" - exit 1 -fi - -SCENARIO="${1:?Usage: ./run.sh [measurement-sec] [server-url]}" -USERS_PER_SEC="${2:?Usage: ./run.sh [measurement-sec] [server-url]}" -MEASUREMENT="${3:-60}" -SERVER_URL="${4:-http://localhost:9090}" -REALM="${5:-test-realm}" - -# Resolve full scenario class name -case "$SCENARIO" in - CreateUsers|CreateDeleteUsers|CreateClients|CreateDeleteClients|CreateRealms) - SCENARIO_CLASS="keycloak.scenario.admin.$SCENARIO" - ;; - ClientSecret|AuthorizationCode) - SCENARIO_CLASS="keycloak.scenario.authentication.$SCENARIO" - ;; - *) - SCENARIO_CLASS="$SCENARIO" - ;; -esac - -CLASSPATH=$(find "$LIB_DIR" -type f -name '*.jar' | tr '\n' ':') - -echo "============================================" -echo " Scenario: $SCENARIO_CLASS" -echo " Users/sec: $USERS_PER_SEC" -echo " Measurement: ${MEASUREMENT}s" -echo " Server: $SERVER_URL" -echo " Realm: $REALM" -echo "============================================" -echo - -java -server -Xmx1G \ - -Dserver-url="$SERVER_URL" \ - -Drealm-name="$REALM" \ - -Dclient-id=gatling \ - -Dclient-secret=setup-for-benchmark \ - -Dusers-per-sec="$USERS_PER_SEC" \ - -Dmeasurement="$MEASUREMENT" \ - -cp "$CLASSPATH" \ - io.gatling.app.Gatling \ - -rf "$RESULTS_DIR" \ - -s "$SCENARIO_CLASS" diff --git a/keycloak-ydb-extension/retry-proxy/README.md b/keycloak-ydb-extension/retry-proxy/README.md index fde3d002..cfbf3757 100644 --- a/keycloak-ydb-extension/retry-proxy/README.md +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -31,6 +31,17 @@ All parameters are set via environment variables. | `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 | From 90e54f3df3907739e7dd44f61a2498643f2ebe59 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 9 Apr 2026 21:47:01 +0300 Subject: [PATCH 049/120] refactor: Remove retry-proxy implementation and leave only load tests in this branch --- .../YdbConnectionProviderFactoryImpl.kt | 8 +- .../connection/YdbEntityManagerProxy.kt | 56 ----- .../connection/YdbJpaKeycloakTransaction.kt | 36 --- .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 13 - .../docker/conf/keycloak-remote-ydb.conf | 5 - .../docker/conf/keycloak.conf | 5 - .../docker/docker-compose-remote-ydb.yml | 21 +- .../docker/docker-compose.yml | 20 +- keycloak-ydb-extension/load-test/README.md | 14 +- keycloak-ydb-extension/pom.xml | 4 - keycloak-ydb-extension/retry-proxy/Dockerfile | 10 - keycloak-ydb-extension/retry-proxy/README.md | 51 ---- keycloak-ydb-extension/retry-proxy/pom.xml | 170 ------------- .../tech/ydb/keycloak/proxy/Dependencies.kt | 14 -- .../tech/ydb/keycloak/proxy/RetryProxy.kt | 19 -- .../ydb/keycloak/proxy/client/ProxyClient.kt | 17 -- .../ydb/keycloak/proxy/config/ProxyConfig.kt | 35 --- .../proxy/controller/ProxyController.kt | 66 ----- .../ydb/keycloak/proxy/plugins/Routing.kt | 15 -- .../keycloak/proxy/service/ProxyService.kt | 121 ---------- .../ydb/keycloak/proxy/utils/HeaderUtils.kt | 19 -- .../proxy/controller/ProxyControllerTest.kt | 127 ---------- .../proxy/service/ProxyServiceTest.kt | 227 ------------------ .../keycloak/proxy/utils/HeaderUtilsTest.kt | 50 ---- .../run-keycloak-with-ydb.sh | 9 +- 25 files changed, 16 insertions(+), 1116 deletions(-) delete mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt delete mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt delete mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/Dockerfile delete mode 100644 keycloak-ydb-extension/retry-proxy/README.md delete mode 100644 keycloak-ydb-extension/retry-proxy/pom.xml delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt 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 index 1350869e..2dbb96c3 100644 --- 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 @@ -12,6 +12,7 @@ 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.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.connections.jpa.updater.JpaUpdaterProvider import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY @@ -58,13 +59,10 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf emf.createEntityManager(SYNCHRONIZED) } - val keycloakEm = EntityManagerProxy.create(session, em, true) - val ydbEm = YdbEntityManagerProxy.create(keycloakEm) - if (!jtaEnabled) { - session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm)) + session.transactionManager.enlist(JpaKeycloakTransaction(em)) } - return DefaultJpaConnectionProvider(ydbEm) + return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) } override fun init(scope: Config.Scope) { 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 deleted file mode 100644 index e1f7215f..00000000 --- a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt +++ /dev/null @@ -1,56 +0,0 @@ -package tech.ydb.keycloak.connection - -import jakarta.persistence.EntityManager -import jakarta.ws.rs.WebApplicationException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response -import org.jboss.logging.Logger -import tech.ydb.keycloak.utils.isYdbRetryable -import java.lang.reflect.InvocationTargetException -import java.lang.reflect.Method -import java.lang.reflect.Proxy - -// TODO: add unit test -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 ydbRetryableResponse(cause) - } - throw cause - } catch (e: Exception) { - if (isYdbRetryable(e)) { - LOG.warn("YDB retryable error during ${method.name}, returning 503") - throw ydbRetryableResponse(e) - } - throw e - } - } - - private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( - cause.message, - cause, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - - 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 deleted file mode 100644 index 202e1106..00000000 --- a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt +++ /dev/null @@ -1,36 +0,0 @@ -package tech.ydb.keycloak.connection - -import jakarta.persistence.EntityManager -import jakarta.ws.rs.WebApplicationException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response -import org.jboss.logging.Logger -import org.keycloak.connections.jpa.JpaKeycloakTransaction -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 WebApplicationException( - "YDB transaction aborted due to contention", - e, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - } - 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/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt deleted file mode 100644 index 7e797251..00000000 --- a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt +++ /dev/null @@ -1,13 +0,0 @@ -package tech.ydb.keycloak.utils - -import tech.ydb.jdbc.exception.YdbRetryableException - -// TODO: add unit test -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/docker/conf/keycloak-remote-ydb.conf b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf index 17ab5a4a..ba0e4166 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf @@ -21,8 +21,3 @@ 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 index c841b2f9..ff0b5d26 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -24,8 +24,3 @@ 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 index 944cfed3..63ae01b6 100644 --- a/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml +++ b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml @@ -8,8 +8,8 @@ services: remote-ydb-keycloak: image: quay.io/keycloak/keycloak:26.4.7 container_name: remote-ydb-keycloak - expose: - - '8080' + ports: + - '9090: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 @@ -21,20 +21,3 @@ services: - 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 index f05d19c2..a6d2daf6 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -38,8 +38,6 @@ services: 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 @@ -47,24 +45,10 @@ services: command: > -v start-dev --cache=local - networks: - - keycloak - depends_on: - ydb: - condition: service_started - - 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 + ydb: + condition: service_started diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index bf66e636..4343024e 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -4,8 +4,8 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/k 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 +- **Keycloak + Local YDB** — YDB in Docker, Keycloak exposed directly +- **Keycloak + Remote YDB** — external YDB instance, Keycloak exposed directly ## Prerequisites @@ -38,7 +38,7 @@ All commands below are run from the `keycloak-ydb-extension/` root. ../run-keycloak-with-ydb.sh ``` -This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Keycloak + retry-proxy). +This builds core, copies the JAR, and starts Docker Compose (YDB + Keycloak). Wait for Keycloak to start (~30-60s). Check logs: @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml logs -f ydb-keycloak | Service | URL | |----------------------------|-----------------------| -| Keycloak (via retry-proxy) | http://localhost:9090 | +| Keycloak | http://localhost:9090 | | YDB Monitoring | http://localhost:8765 | ### Option B: Keycloak + Remote YDB @@ -64,7 +64,7 @@ docker run -d --rm --name ydb-local -h localhost \ ydbplatform/local-ydb:latest ``` -Then start Keycloak + retry-proxy: +Then start Keycloak: ```bash YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ @@ -80,7 +80,7 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. | Service | URL | |----------------------------|-----------------------| -| Keycloak (via retry-proxy) | http://localhost:9090 | +| Keycloak | http://localhost:9090 | Admin credentials for all options: `admin` / `admin` @@ -187,7 +187,7 @@ java -server -Xmx1G \ 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). +Also make sure `hostname` in `docker/conf/keycloak.conf` matches this address. By default, users `user-0`, `user-1`, ... with passwords `user-0-password`, `user-1-password`, ... are used (created by `setup-test-realm.py`). diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index e2dd96fa..a64450f9 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -15,7 +15,6 @@ core test - retry-proxy @@ -37,9 +36,6 @@ 7.0.2 0.9.1 - - 3.4.1 - 1.5.16 diff --git a/keycloak-ydb-extension/retry-proxy/Dockerfile b/keycloak-ydb-extension/retry-proxy/Dockerfile deleted file mode 100644 index 2b38ec93..00000000 --- a/keycloak-ydb-extension/retry-proxy/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index cfbf3757..00000000 --- a/keycloak-ydb-extension/retry-proxy/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index d0fe3bc3..00000000 --- a/keycloak-ydb-extension/retry-proxy/pom.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - 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-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 deleted file mode 100644 index 31a3ac8f..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 9a3cb593..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index a7b6ec57..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index e19fd3df..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 53b5a6de..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt +++ /dev/null @@ -1,66 +0,0 @@ -package tech.ydb.keycloak.proxy.controller - -import io.ktor.http.* -import io.ktor.server.application.* -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.local.remoteHost - val scheme = call.request.local.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}" - - result.headers.forEach { name, values -> - if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) { - values.forEach { value -> - response.header(name, rewriteInternalUrl(name, value, originalHost)) - } - } - } - - respondBytes(result.body, result.contentType, result.status) - } - - private fun rewriteInternalUrl(name: String, value: String, originalHost: String): String { - if (isHeader(name, HttpHeaders.Location)) { - return value.replace(config.targetUrl, "http://$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 deleted file mode 100644 index 04ea9e81..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt +++ /dev/null @@ -1,15 +0,0 @@ -package tech.ydb.keycloak.proxy.plugins - -import io.ktor.server.application.* -import io.ktor.server.routing.* -import tech.ydb.keycloak.proxy.Dependencies - -fun Application.configureRouting(deps: Dependencies) { - 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 deleted file mode 100644 index 4de789d9..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt +++ /dev/null @@ -1,121 +0,0 @@ -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.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() - - val isRetryable = response.status.value == 503 && String(responseBody).contains("ydb_retryable") - - if (isRetryable) { - 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 deleted file mode 100644 index 1f59cae1..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt +++ /dev/null @@ -1,19 +0,0 @@ -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/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 deleted file mode 100644 index 49529930..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -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").let { - assertEquals(HttpStatusCode.Found, it.status) - assertTrue(it.headers[HttpHeaders.Location]!!.contains("/realms/master")) - assertFalse(it.headers[HttpHeaders.Location]!!.contains("backend:8080")) - } - } - - @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 deleted file mode 100644 index 9552cb94..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt +++ /dev/null @@ -1,227 +0,0 @@ -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 deleted file mode 100644 index 437459d2..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -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/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index 6140d57d..68751db6 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -18,18 +18,13 @@ 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) ===" +echo "=== Starting Docker Compose (YDB + Keycloak) ===" 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 " Keycloak: http://localhost:9090" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" From e020e55ec14c0eca12fc96b97076e4954104c107 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 9 Apr 2026 22:00:53 +0300 Subject: [PATCH 050/120] Revert "refactor: Remove retry-proxy implementation and leave only load tests in this branch" This reverts commit 90e54f3df3907739e7dd44f61a2498643f2ebe59. --- .../YdbConnectionProviderFactoryImpl.kt | 8 +- .../connection/YdbEntityManagerProxy.kt | 56 +++++ .../connection/YdbJpaKeycloakTransaction.kt | 36 +++ .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 13 + .../docker/conf/keycloak-remote-ydb.conf | 5 + .../docker/conf/keycloak.conf | 5 + .../docker/docker-compose-remote-ydb.yml | 21 +- .../docker/docker-compose.yml | 20 +- keycloak-ydb-extension/load-test/README.md | 14 +- keycloak-ydb-extension/pom.xml | 4 + keycloak-ydb-extension/retry-proxy/Dockerfile | 10 + keycloak-ydb-extension/retry-proxy/README.md | 51 ++++ keycloak-ydb-extension/retry-proxy/pom.xml | 170 +++++++++++++ .../tech/ydb/keycloak/proxy/Dependencies.kt | 14 ++ .../tech/ydb/keycloak/proxy/RetryProxy.kt | 19 ++ .../ydb/keycloak/proxy/client/ProxyClient.kt | 17 ++ .../ydb/keycloak/proxy/config/ProxyConfig.kt | 35 +++ .../proxy/controller/ProxyController.kt | 66 +++++ .../ydb/keycloak/proxy/plugins/Routing.kt | 15 ++ .../keycloak/proxy/service/ProxyService.kt | 121 ++++++++++ .../ydb/keycloak/proxy/utils/HeaderUtils.kt | 19 ++ .../proxy/controller/ProxyControllerTest.kt | 127 ++++++++++ .../proxy/service/ProxyServiceTest.kt | 227 ++++++++++++++++++ .../keycloak/proxy/utils/HeaderUtilsTest.kt | 50 ++++ .../run-keycloak-with-ydb.sh | 9 +- 25 files changed, 1116 insertions(+), 16 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt create mode 100644 keycloak-ydb-extension/retry-proxy/Dockerfile create mode 100644 keycloak-ydb-extension/retry-proxy/README.md create mode 100644 keycloak-ydb-extension/retry-proxy/pom.xml create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt 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 index 2dbb96c3..1350869e 100644 --- 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 @@ -12,7 +12,6 @@ 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.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.connections.jpa.updater.JpaUpdaterProvider import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY @@ -59,10 +58,13 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf emf.createEntityManager(SYNCHRONIZED) } + val keycloakEm = EntityManagerProxy.create(session, em, true) + val ydbEm = YdbEntityManagerProxy.create(keycloakEm) + if (!jtaEnabled) { - session.transactionManager.enlist(JpaKeycloakTransaction(em)) + session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm)) } - return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) + return DefaultJpaConnectionProvider(ydbEm) } override fun init(scope: Config.Scope) { 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..e1f7215f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt @@ -0,0 +1,56 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.jboss.logging.Logger +import tech.ydb.keycloak.utils.isYdbRetryable +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +// TODO: add unit test +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 ydbRetryableResponse(cause) + } + throw cause + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw ydbRetryableResponse(e) + } + throw e + } + } + + private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( + cause.message, + cause, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + + 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..202e1106 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt @@ -0,0 +1,36 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.jboss.logging.Logger +import org.keycloak.connections.jpa.JpaKeycloakTransaction +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 WebApplicationException( + "YDB transaction aborted due to contention", + e, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + } + 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/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt new file mode 100644 index 00000000..7e797251 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt @@ -0,0 +1,13 @@ +package tech.ydb.keycloak.utils + +import tech.ydb.jdbc.exception.YdbRetryableException + +// TODO: add unit test +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/docker/conf/keycloak-remote-ydb.conf b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf index ba0e4166..17ab5a4a 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf @@ -21,3 +21,8 @@ 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 index ff0b5d26..c841b2f9 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -24,3 +24,8 @@ 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 index 63ae01b6..944cfed3 100644 --- a/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml +++ b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml @@ -8,8 +8,8 @@ services: remote-ydb-keycloak: image: quay.io/keycloak/keycloak:26.4.7 container_name: remote-ydb-keycloak - ports: - - '9090:8080' + 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 @@ -21,3 +21,20 @@ services: - 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 index a6d2daf6..f05d19c2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -38,6 +38,8 @@ services: 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 @@ -45,10 +47,24 @@ services: command: > -v start-dev --cache=local - ports: - - 9090:8080 networks: - keycloak depends_on: ydb: condition: service_started + + 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 diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 4343024e..bf66e636 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -4,8 +4,8 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/k Supports two infrastructure configurations: -- **Keycloak + Local YDB** — YDB in Docker, Keycloak exposed directly -- **Keycloak + Remote YDB** — external YDB instance, Keycloak exposed directly +- **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 @@ -38,7 +38,7 @@ All commands below are run from the `keycloak-ydb-extension/` root. ../run-keycloak-with-ydb.sh ``` -This builds core, copies the JAR, and starts Docker Compose (YDB + Keycloak). +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: @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml logs -f ydb-keycloak | Service | URL | |----------------------------|-----------------------| -| Keycloak | http://localhost:9090 | +| Keycloak (via retry-proxy) | http://localhost:9090 | | YDB Monitoring | http://localhost:8765 | ### Option B: Keycloak + Remote YDB @@ -64,7 +64,7 @@ docker run -d --rm --name ydb-local -h localhost \ ydbplatform/local-ydb:latest ``` -Then start Keycloak: +Then start Keycloak + retry-proxy: ```bash YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ @@ -80,7 +80,7 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. | Service | URL | |----------------------------|-----------------------| -| Keycloak | http://localhost:9090 | +| Keycloak (via retry-proxy) | http://localhost:9090 | Admin credentials for all options: `admin` / `admin` @@ -187,7 +187,7 @@ java -server -Xmx1G \ 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. +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`). diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index a64450f9..e2dd96fa 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -15,6 +15,7 @@ core test + retry-proxy @@ -36,6 +37,9 @@ 7.0.2 0.9.1 + + 3.4.1 + 1.5.16 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..d0fe3bc3 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/pom.xml @@ -0,0 +1,170 @@ + + + 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-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..53b5a6de --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt @@ -0,0 +1,66 @@ +package tech.ydb.keycloak.proxy.controller + +import io.ktor.http.* +import io.ktor.server.application.* +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.local.remoteHost + val scheme = call.request.local.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}" + + result.headers.forEach { name, values -> + if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) { + values.forEach { value -> + response.header(name, rewriteInternalUrl(name, value, originalHost)) + } + } + } + + respondBytes(result.body, result.contentType, result.status) + } + + private fun rewriteInternalUrl(name: String, value: String, originalHost: String): String { + if (isHeader(name, HttpHeaders.Location)) { + return value.replace(config.targetUrl, "http://$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..04ea9e81 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt @@ -0,0 +1,15 @@ +package tech.ydb.keycloak.proxy.plugins + +import io.ktor.server.application.* +import io.ktor.server.routing.* +import tech.ydb.keycloak.proxy.Dependencies + +fun Application.configureRouting(deps: Dependencies) { + 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..4de789d9 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt @@ -0,0 +1,121 @@ +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.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() + + val isRetryable = response.status.value == 503 && String(responseBody).contains("ydb_retryable") + + if (isRetryable) { + 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/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..49529930 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt @@ -0,0 +1,127 @@ +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").let { + assertEquals(HttpStatusCode.Found, it.status) + assertTrue(it.headers[HttpHeaders.Location]!!.contains("/realms/master")) + assertFalse(it.headers[HttpHeaders.Location]!!.contains("backend:8080")) + } + } + + @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/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index 68751db6..6140d57d 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -18,13 +18,18 @@ cp "$JAR_FILE" docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar echo " JAR copied to docker/providers/" echo "" -echo "=== Starting Docker Compose (YDB + Keycloak) ===" +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: http://localhost:9090" +echo " Keycloak (via retry-proxy): http://localhost:9090" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" From 6ca1dd8ef5881f83716457a7aef4fd1939a9796b Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 1 Feb 2026 14:38:10 +0300 Subject: [PATCH 051/120] feat: Add keycloak-ydb-extension module. Implement storing keycloak realm data in ydb --- keycloak-ydb-extension/.gitignore | 4 + keycloak-ydb-extension/README.md | 12 + .../docker/docker-compose.yml | 61 +++++ keycloak-ydb-extension/pom.xml | 232 ++++++++++++++++++ .../run-keycloack-with-ydb.sh | 7 + .../ydb/keycloak/config/ProviderPriority.kt | 5 + .../tech/ydb/keycloak/config/YdbProfile.kt | 9 + .../DefaultYdbConnectionProviderFactory.kt | 80 ++++++ .../connection/YdbConnectionProvider.kt | 8 + .../YdbConnectionProviderFactory.kt | 5 + .../keycloak/connection/YdbConnectionSpi.kt | 17 ++ .../keycloak/migration/YdbMigrationManager.kt | 32 +++ .../ydb/keycloak/realm/YdbRealmProvider.kt | 10 + .../keycloak/realm/YdbRealmProviderFactory.kt | 48 ++++ .../keycloak/transaction/YdbJpaTransaction.kt | 33 +++ .../ydb/keycloak/util/EntityManagerUtils.kt | 144 +++++++++++ .../tech/ydb/keycloak/util/MigrationUtils.kt | 20 ++ .../org.keycloak.models.RealmProviderFactory | 1 + .../services/org.keycloak.provider.Spi | 1 + ...ak.connection.YdbConnectionProviderFactory | 1 + .../17-12-2025-21-07-create-realm-table.sql | 60 +++++ ...25-21-29-create-realm-attributes-table.sql | 9 + ...reate-realm-required-credentials-table.sql | 10 + ...20-create-realm_events_listeners-table.sql | 8 + ...0-21-create-realm-default-groups-table.sql | 8 + ...create-realm-enabled-event-types-table.sql | 8 + ...00-29-create-realm-localizations-table.sql | 8 + ...5-00-30-create-realm-smtp-config-table.sql | 8 + ...1-create-realm-supported-locales-table.sql | 8 + ...0-34-create-default-client-scope-table.sql | 10 + ...00-36-create-authentication-flow-table.sql | 13 + ...-create-authentication-execution-table.sql | 17 ++ ...0-42-create-authenticator-config-table.sql | 9 + ...-create-required-action-provider-table.sql | 14 ++ ...45-create-required-action-config-table.sql | 8 + ...reate-authenticator-config-entry-table.sql | 8 + ...0-12-2025-00-47-create-component-table.sql | 14 ++ ...25-00-48-create-component-config-table.sql | 10 + .../20-12-2025-01-00-create-client-table.sql | 32 +++ ...2-2025-01-01-create-event-entity-table.sql | 18 ++ .../resources/ydb/db.changelog-master.xml | 8 + 41 files changed, 1018 insertions(+) create mode 100644 keycloak-ydb-extension/.gitignore create mode 100644 keycloak-ydb-extension/README.md create mode 100644 keycloak-ydb-extension/docker/docker-compose.yml create mode 100644 keycloak-ydb-extension/pom.xml create mode 100644 keycloak-ydb-extension/run-keycloack-with-ydb.sh create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml 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..4e981983 --- /dev/null +++ b/keycloak-ydb-extension/README.md @@ -0,0 +1,12 @@ +# Keycloak YDB extension + +## Overview +Keycloak extension to store data using YDB + + +## Getting Started +To store keycloak data in ydb. Mount jar build of this project to keycloak. + +### Local development + +`run-keycloack-with-ydb.sh` script builds project and mount jar of this project to keycloak. \ No newline at end of file diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml new file mode 100644 index 00000000..1a38fd9f --- /dev/null +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -0,0 +1,61 @@ +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", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls"] + interval: 10s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:26.4.7 + volumes: + - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + environment: + # YDB Configuration + - KC_COMMUNITY_DATASTORE_YDB_ENABLED=true + - KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local + - KC_SPI_YDB_CONNECTION_DEFAULT_CREATE_SCHEMA=true + # Keycloak Admin + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + entrypoint: /opt/keycloak/bin/kc.sh + command: > + -v start-dev + --cache=local + --features-disabled=authorization,admin-fine-grained-authz,organization + ports: + - 9090:8080 + networks: + - keycloak + depends_on: + ydb: + condition: service_started \ 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..3de2bca7 --- /dev/null +++ b/keycloak-ydb-extension/pom.xml @@ -0,0 +1,232 @@ + + + 4.0.0 + + tech.ydb + keycloak-ydb-extension + 1.0-SNAPSHOT + + + 21 + 21 + 21 + UTF-8 + + 2.2.21 + official + 21 + + 26.4.7 + + 2.3.20 + + 7.0.2 + 1.5.1 + + + + 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-surefire-plugin + 3.5.4 + + + maven-failsafe-plugin + 3.5.4 + + + org.apache.maven.plugins + 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.junit + junit-bom + 6.0.1 + 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 + ${hibernate.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 + + + \ No newline at end of file diff --git a/keycloak-ydb-extension/run-keycloack-with-ydb.sh b/keycloak-ydb-extension/run-keycloack-with-ydb.sh new file mode 100644 index 00000000..fe3724ab --- /dev/null +++ b/keycloak-ydb-extension/run-keycloack-with-ydb.sh @@ -0,0 +1,7 @@ +mvn clean package + +mkdir -p docker/providers + +cp target/keycloak-ydb-extension-1.0-SNAPSHOT.jar docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + +docker-compose -f docker/docker-compose.yml up -d \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt new file mode 100644 index 00000000..342b7066 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt @@ -0,0 +1,5 @@ +package tech.ydb.keycloak.config + +object ProviderPriority { + const val PROVIDER_PRIORITY = 1 +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt new file mode 100644 index 00000000..170f3bc2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt @@ -0,0 +1,9 @@ +package tech.ydb.keycloak.config + +object YdbProfile { + private const val ENV_YDB_PROFILE_ENABLED: String = "KC_COMMUNITY_DATASTORE_YDB_ENABLED" + private const val PROP_YDB_PROFILE_ENABLED: String = "kc.community.datastore.ydb.enabled" + + val IS_YDB_PROFILE_ENABLED = System.getenv(ENV_YDB_PROFILE_ENABLED).toBoolean() + || System.getProperty(PROP_YDB_PROFILE_ENABLED).toBoolean() +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt new file mode 100644 index 00000000..895bb871 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt @@ -0,0 +1,80 @@ +package tech.ydb.keycloak.connection + +import tech.ydb.keycloak.migration.YdbMigrationManager.migrate +import com.zaxxer.hikari.HikariDataSource +import jakarta.persistence.EntityManager +import jakarta.persistence.EntityManagerFactory +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.connections.jpa.support.EntityManagerProxy +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.provider.EnvironmentDependentProviderFactory +import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED +import tech.ydb.keycloak.transaction.YdbJpaTransaction +import tech.ydb.keycloak.util.EntityManagerUtils.createEntityManagerFactory +import tech.ydb.keycloak.util.hikariDataSource + +class DefaultYdbConnectionProviderFactory : YdbConnectionProviderFactory, + EnvironmentDependentProviderFactory { + + private val logger: Logger = Logger.getLogger(DefaultYdbConnectionProviderFactory::class.java) + + private lateinit var dataSource: HikariDataSource + private lateinit var entityManagerFactory: EntityManagerFactory + + override fun create(session: KeycloakSession): YdbConnectionProvider = + createYdbConnectionProvider(session) + + override fun init(scope: Config.Scope) { + val jdbcUrl = scope["jdbcUrl"] + val poolSize = scope.getInt("poolSize", 10) + val connectionTimeout = scope.getLong("connectionTimeout", 5000L) // todo review + val showSql = scope.getBoolean("showSql", false) + val formatSql = scope.getBoolean("formatSql", true) + + dataSource = hikariDataSource(jdbcUrl, poolSize) + + entityManagerFactory = createEntityManagerFactory(dataSource, showSql, formatSql) + + logger.info("YDB connection pool, JOOQ DSLContext and EntityManager configured successfully") + + migrate(dataSource) + } + + override fun postInit(factory: KeycloakSessionFactory) { + // no operations + } + + override fun close() { + entityManagerFactory.close() + dataSource.close() + } + + override fun getId(): String = PROVIDER_ID + + override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED + + private fun createYdbConnectionProvider(session: KeycloakSession): YdbConnectionProvider { + return object : YdbConnectionProvider { + override val entityManager: EntityManager = createEntityManager(session) + + override fun close() { + entityManager.close() + } + } + } + + private fun createEntityManager(session: KeycloakSession): EntityManager { + val em = entityManagerFactory.createEntityManager() + + val tx = YdbJpaTransaction(em) + session.transactionManager.enlist(tx) + + return EntityManagerProxy.create(session, em, true) + } + + private companion object { + private const val PROVIDER_ID: String = "default" + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt new file mode 100644 index 00000000..14d5031c --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt @@ -0,0 +1,8 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import org.keycloak.provider.Provider + +interface YdbConnectionProvider : Provider { + val entityManager: EntityManager +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt new file mode 100644 index 00000000..c7ba02de --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt @@ -0,0 +1,5 @@ +package tech.ydb.keycloak.connection + +import org.keycloak.provider.ProviderFactory + +interface YdbConnectionProviderFactory : ProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt new file mode 100644 index 00000000..1e7e341e --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt @@ -0,0 +1,17 @@ +package tech.ydb.keycloak.connection + +import org.keycloak.provider.Spi + +class YdbConnectionSpi : Spi { + override fun isInternal(): Boolean = true + + override fun getName() = NAME + + override fun getProviderClass() = YdbConnectionProvider::class.java + + override fun getProviderFactoryClass() = YdbConnectionProviderFactory::class.java + + companion object { + private const val NAME: String = "ydbConnection" + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt new file mode 100644 index 00000000..8b7fc497 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt @@ -0,0 +1,32 @@ +package tech.ydb.keycloak.migration + +import liquibase.Liquibase +import liquibase.database.jvm.JdbcConnection +import liquibase.exception.LiquibaseException +import liquibase.resource.ClassLoaderResourceAccessor +import org.jboss.logging.Logger +import java.sql.SQLException +import javax.sql.DataSource + +object YdbMigrationManager { + private const val CHANGELOG_FILE: String = "ydb/db.changelog-master.xml" + + private val logger = Logger.getLogger(YdbMigrationManager::class.java) + + fun migrate(dataSource: DataSource) { + logger.info("Starting YDB migrations using Liquibase...") + + try { + dataSource.connection.use { connection -> + Liquibase(CHANGELOG_FILE, ClassLoaderResourceAccessor(), JdbcConnection(connection)).use { liquibase -> + liquibase.update() + logger.info("YDB migrations completed successfully") + } + } + } catch (e: LiquibaseException) { + logger.error("Failed to execute YDB migrations", e) + + throw SQLException("Failed to execute YDB migrations", e) + } + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt new file mode 100644 index 00000000..47ad8878 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -0,0 +1,10 @@ +package tech.ydb.keycloak.realm + +import jakarta.persistence.EntityManager +import org.keycloak.models.KeycloakSession +import org.keycloak.models.jpa.JpaRealmProvider + +class YdbRealmProvider( + session: KeycloakSession, + entityManager: EntityManager, +) : JpaRealmProvider(session, entityManager, null, null) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt new file mode 100644 index 00000000..935a0981 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -0,0 +1,48 @@ +package tech.ydb.keycloak.realm + +import org.jboss.logging.Logger +import org.keycloak.Config +import org.keycloak.models.KeycloakSession +import org.keycloak.models.KeycloakSessionFactory +import org.keycloak.models.RealmProviderFactory +import org.keycloak.provider.EnvironmentDependentProviderFactory +import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED +import tech.ydb.keycloak.connection.YdbConnectionProvider + +class YdbRealmProviderFactory() : RealmProviderFactory, EnvironmentDependentProviderFactory { + + private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) + + override fun create(session: KeycloakSession): YdbRealmProvider { + val provider = session.getProvider(YdbConnectionProvider::class.java)?.let { + YdbRealmProvider(session, it.entityManager) + } ?: error("YdbConnectionProvider is not configured") + + 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 = ID + + override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED + + override fun order(): Int = PROVIDER_PRIORITY + 1 + + private companion object { + private const val ID = "ydb-realm-provider-factory" + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt new file mode 100644 index 00000000..da876df5 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt @@ -0,0 +1,33 @@ +package tech.ydb.keycloak.transaction + +import jakarta.persistence.EntityManager +import org.keycloak.models.KeycloakTransaction + +class YdbJpaTransaction( + private val em: EntityManager +) : KeycloakTransaction { + + override fun begin() { + em.transaction.begin() + } + + override fun commit() { + em.transaction.commit() + } + + override fun rollback() { + if (em.transaction.isActive) { + em.transaction.rollback() + } + } + + override fun setRollbackOnly() { + if (em.transaction.isActive) { + em.transaction.setRollbackOnly() + } + } + + override fun getRollbackOnly(): Boolean = em.transaction.rollbackOnly + + override fun isActive(): Boolean = em.transaction.isActive +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt new file mode 100644 index 00000000..d9580ff9 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt @@ -0,0 +1,144 @@ +package tech.ydb.keycloak.util + +import com.zaxxer.hikari.HikariDataSource +import jakarta.persistence.EntityManagerFactory +import org.hibernate.boot.registry.StandardServiceRegistryBuilder +import org.hibernate.cfg.AvailableSettings +import org.hibernate.cfg.Configuration +import org.jboss.logging.Logger + +object EntityManagerUtils { + private val logger = Logger.getLogger(EntityManagerUtils::class.java) + + fun createEntityManagerFactory(dataSource: HikariDataSource, showSql: Boolean, formatSql: Boolean): EntityManagerFactory { + logger.info("Creating YDB EntityManagerFactory programmatically") + + val configuration = Configuration() + + configuration.setProperty(AvailableSettings.DIALECT, "tech.ydb.hibernate.dialect.YdbDialect") + configuration.setProperty(AvailableSettings.HBM2DDL_AUTO, "none") + configuration.setProperty(AvailableSettings.SHOW_SQL, showSql) + configuration.setProperty(AvailableSettings.FORMAT_SQL, formatSql) + + configuration.setProperty(AvailableSettings.STATEMENT_BATCH_SIZE, "0") + configuration.setProperty(AvailableSettings.ORDER_INSERTS, "false") + configuration.setProperty(AvailableSettings.ORDER_UPDATES, "false") + + configuration.setProperty(AvailableSettings.DEFAULT_BATCH_FETCH_SIZE, "8") + configuration.setProperty(AvailableSettings.USE_SQL_COMMENTS, "false") + configuration.setProperty("hibernate.query.in_clause_parameter_padding", "true") + configuration.setProperty(AvailableSettings.STATEMENT_FETCH_SIZE, "64") + + addKeycloakEntities(configuration) + + val serviceRegistry = StandardServiceRegistryBuilder() + // TODO use not deprecated instead of DATASOURCE + .applySetting(AvailableSettings.DATASOURCE, dataSource) + .applySettings(configuration.properties) + .build() + + val sessionFactory = configuration.buildSessionFactory(serviceRegistry) + + logger.info("YDB EntityManagerFactory created successfully") + + return sessionFactory.unwrap(EntityManagerFactory::class.java) + } + + fun addKeycloakEntities(configuration: Configuration) { + logger.debug("Adding Keycloak entity classes") + + val entityClasses = listOf( + "org.keycloak.models.jpa.entities.ClientEntity", + "org.keycloak.models.jpa.entities.ClientAttributeEntity", + "org.keycloak.models.jpa.entities.CredentialEntity", + "org.keycloak.models.jpa.entities.RealmEntity", + "org.keycloak.models.jpa.entities.RealmAttributeEntity", + "org.keycloak.models.jpa.entities.RequiredCredentialEntity", + "org.keycloak.models.jpa.entities.ComponentConfigEntity", + "org.keycloak.models.jpa.entities.ComponentEntity", + "org.keycloak.models.jpa.entities.UserFederationProviderEntity", + "org.keycloak.models.jpa.entities.UserFederationMapperEntity", + "org.keycloak.models.jpa.entities.RoleEntity", + "org.keycloak.models.jpa.entities.RoleAttributeEntity", + "org.keycloak.models.jpa.entities.FederatedIdentityEntity", + "org.keycloak.models.jpa.entities.MigrationModelEntity", + "org.keycloak.models.jpa.entities.UserEntity", + "org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity", + "org.keycloak.models.jpa.entities.UserRequiredActionEntity", + "org.keycloak.models.jpa.entities.UserAttributeEntity", + "org.keycloak.models.jpa.entities.UserRoleMappingEntity", + "org.keycloak.models.jpa.entities.IdentityProviderEntity", + "org.keycloak.models.jpa.entities.IdentityProviderMapperEntity", + "org.keycloak.models.jpa.entities.ProtocolMapperEntity", + "org.keycloak.models.jpa.entities.UserConsentEntity", + "org.keycloak.models.jpa.entities.UserConsentClientScopeEntity", + "org.keycloak.models.jpa.entities.AuthenticationFlowEntity", + "org.keycloak.models.jpa.entities.AuthenticationExecutionEntity", + "org.keycloak.models.jpa.entities.AuthenticatorConfigEntity", + "org.keycloak.models.jpa.entities.RequiredActionProviderEntity", + "org.keycloak.models.jpa.session.PersistentUserSessionEntity", + "org.keycloak.models.jpa.session.PersistentClientSessionEntity", + "org.keycloak.models.jpa.entities.RevokedTokenEntity", + "org.keycloak.models.jpa.entities.GroupEntity", + "org.keycloak.models.jpa.entities.GroupAttributeEntity", + "org.keycloak.models.jpa.entities.GroupRoleMappingEntity", + "org.keycloak.models.jpa.entities.UserGroupMembershipEntity", + "org.keycloak.models.jpa.entities.ClientScopeEntity", + "org.keycloak.models.jpa.entities.ClientScopeAttributeEntity", + "org.keycloak.models.jpa.entities.ClientScopeRoleMappingEntity", + "org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity", + "org.keycloak.models.jpa.entities.DefaultClientScopeRealmMappingEntity", + "org.keycloak.models.jpa.entities.ClientInitialAccessEntity", + + // Events + "org.keycloak.events.jpa.EventEntity", + "org.keycloak.events.jpa.AdminEventEntity", + + // Authorization + "org.keycloak.authorization.jpa.entities.ResourceServerEntity", + "org.keycloak.authorization.jpa.entities.ResourceEntity", + "org.keycloak.authorization.jpa.entities.ScopeEntity", + "org.keycloak.authorization.jpa.entities.PolicyEntity", + "org.keycloak.authorization.jpa.entities.PermissionTicketEntity", + "org.keycloak.authorization.jpa.entities.ResourceAttributeEntity", + + // Federated storage + "org.keycloak.storage.jpa.entity.BrokerLinkEntity", + "org.keycloak.storage.jpa.entity.FederatedUser", + "org.keycloak.storage.jpa.entity.FederatedUserAttributeEntity", + "org.keycloak.storage.jpa.entity.FederatedUserConsentEntity", + "org.keycloak.storage.jpa.entity.FederatedUserConsentClientScopeEntity", + "org.keycloak.storage.jpa.entity.FederatedUserCredentialEntity", + "org.keycloak.storage.jpa.entity.FederatedUserGroupMembershipEntity", + "org.keycloak.storage.jpa.entity.FederatedUserRequiredActionEntity", + "org.keycloak.storage.jpa.entity.FederatedUserRoleMappingEntity", + + // Organization + "org.keycloak.models.jpa.entities.OrganizationEntity", + "org.keycloak.models.jpa.entities.OrganizationDomainEntity", + + // Server config + "org.keycloak.storage.configuration.jpa.entity.ServerConfigEntity", + + // Workflows + "org.keycloak.models.workflow.WorkflowStateEntity" + ) + + var addedCount = 0 + var failedCount = 0 + + entityClasses.forEach { className -> + try { + val clazz = Class.forName(className) + configuration.addAnnotatedClass(clazz) + addedCount++ + } catch (e: ClassNotFoundException) { + logger.warn("Entity class not found: $className", e) + failedCount++ + } + } + + logger.info("Added $addedCount entity classes, $failedCount not found") + } + +} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt new file mode 100644 index 00000000..7d966fa3 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt @@ -0,0 +1,20 @@ +package tech.ydb.keycloak.util + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource + +fun hikariDataSource( + jdbcUrl: String?, + poolSize: Int, +): HikariDataSource = HikariDataSource(hikariConfig(jdbcUrl, poolSize)) + +fun hikariConfig( + jdbcUrl: String?, + poolSize: Int, +): HikariConfig = HikariConfig().apply {// todo Review how to create connections correctly. + this.jdbcUrl = jdbcUrl + this.driverClassName = "tech.ydb.jdbc.YdbDriver" + this.maximumPoolSize = poolSize + this.poolName = "YDB-HikariPool" + this.isAutoCommit = false // todo review +} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory new file mode 100644 index 00000000..fb8ab02d --- /dev/null +++ b/keycloak-ydb-extension/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/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100644 index 00000000..9321db82 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1 @@ +tech.ydb.keycloak.connection.YdbConnectionSpi \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory new file mode 100644 index 00000000..cf141884 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql new file mode 100644 index 00000000..0ea8a8ea --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-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/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql new file mode 100644 index 00000000..f0412189 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql @@ -0,0 +1,9 @@ +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), + PRIMARY KEY (NAME, REALM_ID), +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql new file mode 100644 index 00000000..6b6a2d3b --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql @@ -0,0 +1,10 @@ +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, + + primary key (REALM_ID, TYPE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql new file mode 100644 index 00000000..467db2ce --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql new file mode 100644 index 00000000..eb9c592a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `GROUP_ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql new file mode 100644 index 00000000..d8a06a35 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql new file mode 100644 index 00000000..3fb00d41 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql new file mode 100644 index 00000000..1dc313c7 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `REALM_SMTP_CONFIG` +( + `REALM_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + PRIMARY KEY (`REALM_ID`, `NAME`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql new file mode 100644 index 00000000..60694555 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql @@ -0,0 +1,8 @@ +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`), + PRIMARY KEY (`REALM_ID`, `VALUE`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql new file mode 100644 index 00000000..caed1def --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql @@ -0,0 +1,10 @@ +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`), + PRIMARY KEY (`REALM_ID`, `SCOPE_ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql new file mode 100644 index 00000000..f34deebe --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql @@ -0,0 +1,13 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql new file mode 100644 index 00000000..f77ba038 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql @@ -0,0 +1,17 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql new file mode 100644 index 00000000..2e2f290e --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql @@ -0,0 +1,9 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql new file mode 100644 index 00000000..ce278295 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql @@ -0,0 +1,14 @@ +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`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql new file mode 100644 index 00000000..5cfcf531 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql new file mode 100644 index 00000000..53c25a4f --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql new file mode 100644 index 00000000..e73f2f9d --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql @@ -0,0 +1,14 @@ +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), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql new file mode 100644 index 00000000..b5117982 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql @@ -0,0 +1,10 @@ +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), + PRIMARY KEY (ID) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql new file mode 100644 index 00000000..132067ac --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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 ON (`CLIENT_ID`), + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql new file mode 100644 index 00000000..67837bac --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-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/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml new file mode 100644 index 00000000..1112dc98 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml @@ -0,0 +1,8 @@ + + + + From fd744fe609586ee0d81d274ad44186e6db8455e1 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 1 Feb 2026 15:42:10 +0300 Subject: [PATCH 052/120] chore: Make migrations name in format year-month-day-date-{name-of-migration} --- ...te-realm-table.sql => 2025-12-17-21-07-create-realm-table.sql} | 0 ...ble.sql => 2025-12-17-21-29-create-realm-attributes-table.sql} | 0 ... 2025-12-17-22-00-create-realm-required-credentials-table.sql} | 0 ...l => 2025-12-20-00-20-create-realm_events_listeners-table.sql} | 0 ...sql => 2025-12-20-00-21-create-realm-default-groups-table.sql} | 0 ...> 2025-12-20-00-28-create-realm-enabled-event-types-table.sql} | 0 ....sql => 2025-12-20-00-29-create-realm-localizations-table.sql} | 0 ...le.sql => 2025-12-20-00-30-create-realm-smtp-config-table.sql} | 0 ... => 2025-12-20-00-31-create-realm-supported-locales-table.sql} | 0 ...sql => 2025-12-20-00-34-create-default-client-scope-table.sql} | 0 ....sql => 2025-12-20-00-36-create-authentication-flow-table.sql} | 0 ...=> 2025-12-20-00-38-create-authentication-execution-table.sql} | 0 ...sql => 2025-12-20-00-42-create-authenticator-config-table.sql} | 0 ...=> 2025-12-20-00-44-create-required-action-provider-table.sql} | 0 ...l => 2025-12-20-00-45-create-required-action-config-table.sql} | 0 ... 2025-12-20-00-46-create-authenticator-config-entry-table.sql} | 0 ...nent-table.sql => 2025-12-20-00-47-create-component-table.sql} | 0 ...ble.sql => 2025-12-20-00-48-create-component-config-table.sql} | 0 ...-client-table.sql => 2025-12-20-01-00-create-client-table.sql} | 0 ...y-table.sql => 2025-12-20-01-01-create-event-entity-table.sql} | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{17-12-2025-21-07-create-realm-table.sql => 2025-12-17-21-07-create-realm-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{17-12-2025-21-29-create-realm-attributes-table.sql => 2025-12-17-21-29-create-realm-attributes-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{17-12-2025-22-00-create-realm-required-credentials-table.sql => 2025-12-17-22-00-create-realm-required-credentials-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-20-create-realm_events_listeners-table.sql => 2025-12-20-00-20-create-realm_events_listeners-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-21-create-realm-default-groups-table.sql => 2025-12-20-00-21-create-realm-default-groups-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-28-create-realm-enabled-event-types-table.sql => 2025-12-20-00-28-create-realm-enabled-event-types-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-29-create-realm-localizations-table.sql => 2025-12-20-00-29-create-realm-localizations-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-30-create-realm-smtp-config-table.sql => 2025-12-20-00-30-create-realm-smtp-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-31-create-realm-supported-locales-table.sql => 2025-12-20-00-31-create-realm-supported-locales-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-34-create-default-client-scope-table.sql => 2025-12-20-00-34-create-default-client-scope-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-36-create-authentication-flow-table.sql => 2025-12-20-00-36-create-authentication-flow-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-38-create-authentication-execution-table.sql => 2025-12-20-00-38-create-authentication-execution-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-42-create-authenticator-config-table.sql => 2025-12-20-00-42-create-authenticator-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-44-create-required-action-provider-table.sql => 2025-12-20-00-44-create-required-action-provider-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-45-create-required-action-config-table.sql => 2025-12-20-00-45-create-required-action-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-46-create-authenticator-config-entry-table.sql => 2025-12-20-00-46-create-authenticator-config-entry-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-47-create-component-table.sql => 2025-12-20-00-47-create-component-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-00-48-create-component-config-table.sql => 2025-12-20-00-48-create-component-config-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-01-00-create-client-table.sql => 2025-12-20-01-00-create-client-table.sql} (100%) rename keycloak-ydb-extension/src/main/resources/ydb/changesets/{20-12-2025-01-01-create-event-entity-table.sql => 2025-12-20-01-01-create-event-entity-table.sql} (100%) diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-07-create-realm-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-21-29-create-realm-attributes-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/17-12-2025-22-00-create-realm-required-credentials-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-20-create-realm_events_listeners-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-21-create-realm-default-groups-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-28-create-realm-enabled-event-types-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-29-create-realm-localizations-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-30-create-realm-smtp-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-31-create-realm-supported-locales-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-34-create-default-client-scope-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-36-create-authentication-flow-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-38-create-authentication-execution-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-42-create-authenticator-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-44-create-required-action-provider-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-45-create-required-action-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-46-create-authenticator-config-entry-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-47-create-component-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-00-48-create-component-config-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-00-create-client-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/20-12-2025-01-01-create-event-entity-table.sql rename to keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql From 2b71ba4cbf483bdab6c8faec1c81a43edc87c052 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Feb 2026 02:06:02 +0300 Subject: [PATCH 053/120] feat: Implement JpaConnectionProviderFactory to not implement own YdbJpaTransaction --- .../DefaultYdbConnectionProviderFactory.kt | 79 +++++++++++++------ .../connection/YdbConnectionProvider.kt | 5 +- .../keycloak/realm/YdbRealmProviderFactory.kt | 6 +- .../keycloak/transaction/YdbJpaTransaction.kt | 33 -------- ...nections.jpa.JpaConnectionProviderFactory} | 2 +- 5 files changed, 61 insertions(+), 64 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt rename keycloak-ydb-extension/src/main/resources/META-INF/services/{tech.ydb.keycloak.connection.YdbConnectionProviderFactory => org.keycloak.connections.jpa.JpaConnectionProviderFactory} (98%) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt index 895bb871..6bad9714 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt @@ -1,21 +1,27 @@ package tech.ydb.keycloak.connection -import tech.ydb.keycloak.migration.YdbMigrationManager.migrate import com.zaxxer.hikari.HikariDataSource import jakarta.persistence.EntityManager import jakarta.persistence.EntityManagerFactory import org.jboss.logging.Logger import org.keycloak.Config +import org.keycloak.connections.jpa.DefaultJpaConnectionProvider +import org.keycloak.connections.jpa.JpaConnectionProvider +import org.keycloak.connections.jpa.JpaConnectionProviderFactory +import org.keycloak.connections.jpa.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.provider.EnvironmentDependentProviderFactory +import org.keycloak.provider.ServerInfoAwareProviderFactory import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -import tech.ydb.keycloak.transaction.YdbJpaTransaction +import tech.ydb.keycloak.migration.YdbMigrationManager.migrate import tech.ydb.keycloak.util.EntityManagerUtils.createEntityManagerFactory import tech.ydb.keycloak.util.hikariDataSource +import java.sql.Connection -class DefaultYdbConnectionProviderFactory : YdbConnectionProviderFactory, +class DefaultYdbConnectionProviderFactory : JpaConnectionProviderFactory, + ServerInfoAwareProviderFactory, EnvironmentDependentProviderFactory { private val logger: Logger = Logger.getLogger(DefaultYdbConnectionProviderFactory::class.java) @@ -23,11 +29,21 @@ class DefaultYdbConnectionProviderFactory : YdbConnectionProviderFactory = mapOf( + "YDB" to "enabled", + "Pool" to dataSource.hikariPoolMXBean.activeConnections.toString(), + ) - private fun createEntityManager(session: KeycloakSession): EntityManager { - val em = entityManagerFactory.createEntityManager() + private fun createEntityManager(session: KeycloakSession, emf: EntityManagerFactory): EntityManager { + val em = emf.createEntityManager() - val tx = YdbJpaTransaction(em) + val tx = JpaKeycloakTransaction(em) session.transactionManager.enlist(tx) return EntityManagerProxy.create(session, em, true) } private companion object { - private const val PROVIDER_ID: String = "default" + private const val PROVIDER_ID = "default" + private const val ORDER_YDB_FIRST = 2 + private const val schemaName = "public" } -} \ No newline at end of file +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt index 14d5031c..c4de0290 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt @@ -1,8 +1,5 @@ package tech.ydb.keycloak.connection -import jakarta.persistence.EntityManager import org.keycloak.provider.Provider -interface YdbConnectionProvider : Provider { - val entityManager: EntityManager -} +interface YdbConnectionProvider : Provider diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 935a0981..37bf866d 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -6,18 +6,18 @@ import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.models.RealmProviderFactory import org.keycloak.provider.EnvironmentDependentProviderFactory +import org.keycloak.connections.jpa.JpaConnectionProvider import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -import tech.ydb.keycloak.connection.YdbConnectionProvider class YdbRealmProviderFactory() : RealmProviderFactory, EnvironmentDependentProviderFactory { private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) override fun create(session: KeycloakSession): YdbRealmProvider { - val provider = session.getProvider(YdbConnectionProvider::class.java)?.let { + val provider = session.getProvider(JpaConnectionProvider::class.java)?.let { YdbRealmProvider(session, it.entityManager) - } ?: error("YdbConnectionProvider is not configured") + } ?: error("JpaConnectionProvider is not configured in YDB") logger.info("YdbRealmProvider successfully created") diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt deleted file mode 100644 index da876df5..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/transaction/YdbJpaTransaction.kt +++ /dev/null @@ -1,33 +0,0 @@ -package tech.ydb.keycloak.transaction - -import jakarta.persistence.EntityManager -import org.keycloak.models.KeycloakTransaction - -class YdbJpaTransaction( - private val em: EntityManager -) : KeycloakTransaction { - - override fun begin() { - em.transaction.begin() - } - - override fun commit() { - em.transaction.commit() - } - - override fun rollback() { - if (em.transaction.isActive) { - em.transaction.rollback() - } - } - - override fun setRollbackOnly() { - if (em.transaction.isActive) { - em.transaction.setRollbackOnly() - } - } - - override fun getRollbackOnly(): Boolean = em.transaction.rollbackOnly - - override fun isActive(): Boolean = em.transaction.isActive -} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory similarity index 98% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory rename to keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory index cf141884..334d3371 100644 --- a/keycloak-ydb-extension/src/main/resources/META-INF/services/tech.ydb.keycloak.connection.YdbConnectionProviderFactory +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory @@ -1 +1 @@ -tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory \ No newline at end of file +tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory From b239225e2bc14019758672ec889918ef9a0beddf Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:33:05 +0300 Subject: [PATCH 054/120] feat: Use keycloak like code to create connection with YDB. Use YdbDatabase from liquibase-dialect in YdbLiquibaseConnectionProvider --- keycloak-ydb-extension/README.md | 32 +- .../docker/docker-compose.yml | 18 +- keycloak-ydb-extension/pom.xml | 3 + .../run-keycloack-with-ydb.sh | 12 +- .../ydb/keycloak/config/ProviderPriority.kt | 3 +- .../DefaultYdbConnectionProviderFactory.kt | 113 ------ .../YdbConnectionProviderFactoryImpl.kt | 323 ++++++++++++++++++ .../YdbLiquibaseConnectionProvider.kt | 81 +++++ .../keycloak/migration/YdbMigrationManager.kt | 32 -- .../keycloak/realm/YdbRealmProviderFactory.kt | 2 +- .../ydb/keycloak/util/EntityManagerUtils.kt | 144 -------- .../tech/ydb/keycloak/util/MigrationUtils.kt | 20 -- ...nnections.jpa.JpaConnectionProviderFactory | 2 +- ...se.conn.LiquibaseConnectionProviderFactory | 1 + 14 files changed, 461 insertions(+), 325 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 4e981983..70b83059 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -1,12 +1,34 @@ # Keycloak YDB extension ## Overview -Keycloak extension to store data using YDB +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. -## Getting Started -To store keycloak data in ydb. Mount jar build of this project to keycloak. +## Configuration -### Local development +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: -`run-keycloack-with-ydb.sh` script builds project and mount jar of this project to keycloak. \ No newline at end of file +| Variable | Value | Purpose | +|----------|--------|---------| +| `KC_DB` | `dev-file` | Built-in datasource uses dev-file; it never sees the YDB URL. | +| `KC_YDB_URL` | `jdbc:ydb:grpc://host:2136/database` | JDBC URL used by this extension. | +| `KC_COMMUNITY_DATASTORE_YDB_ENABLED` | `true` | Enables this extension’s JPA provider. | +| `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). | + +Alternative: you can set `KC_DB_URL` instead of `KC_YDB_URL`; the extension will use it when the YDB profile is enabled. Then still set `KC_DB=dev-file` so the default pool is not created with that URL. + +## 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-keycloack-with-ydb.sh +``` + +This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. \ No newline at end of file diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 1a38fd9f..6f1b6d27 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -40,17 +40,21 @@ services: volumes: - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar environment: - # YDB Configuration + # Use dev-file for the built-in datasource so it never sees the YDB URL (no "driver does not support URL" error). + # YDB JDBC URL (config key ydbJdbcUrl / ydb-url; env KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL). + - KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local + - KC_SPI_CONNECTIONS_JPA_SHOW_SQL=true - KC_COMMUNITY_DATASTORE_YDB_ENABLED=true - - KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local - - KC_SPI_YDB_CONNECTION_DEFAULT_CREATE_SCHEMA=true - # Keycloak Admin + # Disable default JPA and Liquibase providers so H2 is not used (only YDB is used). + - KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED=false + - KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED=false + # Admin - KEYCLOAK_ADMIN=admin - KEYCLOAK_ADMIN_PASSWORD=admin entrypoint: /opt/keycloak/bin/kc.sh command: > - -v start-dev - --cache=local + -v start-dev + --cache=local --features-disabled=authorization,admin-fine-grained-authz,organization ports: - 9090:8080 @@ -58,4 +62,4 @@ services: - keycloak depends_on: ydb: - condition: service_started \ No newline at end of file + condition: service_started diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index 3de2bca7..560fa287 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -202,6 +202,9 @@ tech.ydb.dialects hibernate-ydb-dialect ${hibernate.ydb.dialect.version} + + + diff --git a/keycloak-ydb-extension/run-keycloack-with-ydb.sh b/keycloak-ydb-extension/run-keycloack-with-ydb.sh index fe3724ab..2ec18c34 100644 --- a/keycloak-ydb-extension/run-keycloack-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloack-with-ydb.sh @@ -1,7 +1,17 @@ +rm -f docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar + mvn clean package mkdir -p docker/providers -cp target/keycloak-ydb-extension-1.0-SNAPSHOT.jar docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar +JAR_FILE="target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" + +if [ ! -f "$JAR_FILE" ]; then + echo "Ошибка: Файл $JAR_FILE не найден!" + echo "Сборка проекта, возможно, завершилась неудачно." + exit 1 +fi + +cp "$JAR_FILE" docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar docker-compose -f docker/docker-compose.yml up -d \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt index 342b7066..7abe5c15 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt @@ -1,5 +1,6 @@ package tech.ydb.keycloak.config object ProviderPriority { - const val PROVIDER_PRIORITY = 1 + // more than in quarkus provider factories + const val PROVIDER_PRIORITY = 200 } diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt deleted file mode 100644 index 6bad9714..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/DefaultYdbConnectionProviderFactory.kt +++ /dev/null @@ -1,113 +0,0 @@ -package tech.ydb.keycloak.connection - -import com.zaxxer.hikari.HikariDataSource -import jakarta.persistence.EntityManager -import jakarta.persistence.EntityManagerFactory -import org.jboss.logging.Logger -import org.keycloak.Config -import org.keycloak.connections.jpa.DefaultJpaConnectionProvider -import org.keycloak.connections.jpa.JpaConnectionProvider -import org.keycloak.connections.jpa.JpaConnectionProviderFactory -import org.keycloak.connections.jpa.JpaKeycloakTransaction -import org.keycloak.connections.jpa.support.EntityManagerProxy -import org.keycloak.models.KeycloakSession -import org.keycloak.models.KeycloakSessionFactory -import org.keycloak.provider.EnvironmentDependentProviderFactory -import org.keycloak.provider.ServerInfoAwareProviderFactory -import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -import tech.ydb.keycloak.migration.YdbMigrationManager.migrate -import tech.ydb.keycloak.util.EntityManagerUtils.createEntityManagerFactory -import tech.ydb.keycloak.util.hikariDataSource -import java.sql.Connection - -class DefaultYdbConnectionProviderFactory : JpaConnectionProviderFactory, - ServerInfoAwareProviderFactory, - EnvironmentDependentProviderFactory { - - private val logger: Logger = Logger.getLogger(DefaultYdbConnectionProviderFactory::class.java) - - private lateinit var dataSource: HikariDataSource - private lateinit var entityManagerFactory: EntityManagerFactory - - override fun create(session: KeycloakSession): JpaConnectionProvider { - val em = createEntityManager(session, entityManagerFactory) - return DefaultJpaConnectionProvider(em) - } - - override fun init(scope: Config.Scope) { - if (!isSupported(scope)) { - logger.debug("YDB JPA provider disabled (profile not enabled), skipping init") - return - } - val jdbcUrl = resolveJdbcUrl(scope) - if (jdbcUrl.isNullOrBlank()) { - logger.warn("YDB JPA provider enabled but no JDBC URL configured. Set KC_SPI_CONNECTIONS_JPA_DEFAULT_JDBC_URL (or KC_DB_JDBC_URL) or legacy KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL. Skipping init.") - return - } - val poolSize = scope.getInt("poolSize", 10) - val connectionTimeout = scope.getLong("connectionTimeout", 5000L) // todo review - val showSql = scope.getBoolean("showSql", false) - val formatSql = scope.getBoolean("formatSql", true) - - dataSource = hikariDataSource(jdbcUrl, poolSize) - - // TODO: maybe reuse JpaUtils.createEntityManagerFactory - // as it is inside DefaultJpaConnectionProviderFactory.lazyInit - entityManagerFactory = createEntityManagerFactory(dataSource, showSql, formatSql) - - logger.info("YDB connection pool and EntityManagerFactory configured successfully") - migrate(dataSource) - } - - // TODO: simplify this - private fun resolveJdbcUrl(scope: Config.Scope): String? = - scope["jdbcUrl"]?.takeIf { it.isNotBlank() } - ?: scope["url"]?.takeIf { it.isNotBlank() } - ?: scope["jdbc-url"]?.takeIf { it.isNotBlank() } - ?: System.getenv("KC_SPI_YDB_CONNECTION_DEFAULT_JDBC_URL")?.takeIf { it.isNotBlank() } - ?: System.getenv("KC_DB_JDBC_URL")?.takeIf { it.isNotBlank() } - ?: System.getenv("KC_DB_URL")?.takeIf { it.isNotBlank() } - - override fun postInit(factory: KeycloakSessionFactory) { - // no operations - } - - override fun close() { - if (::entityManagerFactory.isInitialized) { - entityManagerFactory.close() - } - if (::dataSource.isInitialized) { - dataSource.close() - } - } - - override fun getId(): String = PROVIDER_ID - - override fun order(): Int = ORDER_YDB_FIRST - - override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - - override fun getConnection(): Connection = dataSource.connection - - override fun getSchema(): String = schemaName - - override fun getOperationalInfo(): Map = mapOf( - "YDB" to "enabled", - "Pool" to dataSource.hikariPoolMXBean.activeConnections.toString(), - ) - - private fun createEntityManager(session: KeycloakSession, emf: EntityManagerFactory): EntityManager { - val em = emf.createEntityManager() - - val tx = JpaKeycloakTransaction(em) - session.transactionManager.enlist(tx) - - return EntityManagerProxy.create(session, em, true) - } - - private companion object { - private const val PROVIDER_ID = "default" - private const val ORDER_YDB_FIRST = 2 - private const val schemaName = "public" - } -} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt new file mode 100644 index 00000000..c02fc94d --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -0,0 +1,323 @@ +package tech.ydb.keycloak.connection + +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.JpaKeycloakTransaction +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.EnvironmentDependentProviderFactory +import org.keycloak.provider.ServerInfoAwareProviderFactory +import tech.ydb.hibernate.dialect.YdbDialect +import tech.ydb.jdbc.YdbDriver +import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED +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, + EnvironmentDependentProviderFactory { + + private val logger: Logger = Logger.getLogger(YdbConnectionProviderFactoryImpl::class.java) + + private lateinit var config: Config.Scope + + private var jtaEnabled by Delegates.notNull() + + @Volatile + private lateinit var entityManagerFactory: EntityManagerFactory + + 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) + } + + if (!jtaEnabled) { + session.transactionManager.enlist(JpaKeycloakTransaction(em)) + } + return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) + } + + override fun init(scope: Config.Scope) { + if (!isSupported(scope)) { + logger.debug("YDB JPA disabled (profile not enabled), skipping init") + return + } + config = scope + } + + 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) } + } + + // TODO: FIND OUT HOW IT SMTH LIKE spi.connections-jpa.default.ydb-jdbc-url + private fun resolveJdbcUrl(): String = requireNotNull(config["ydbJdbcUrl"]) { + " YDB JDBC URL is required. Set env variable KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL= " + } + + 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() + } + } + + override fun getId(): String = "default" + + override fun order(): Int = PROVIDER_PRIORITY + + override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED + + override fun getConnection(): Connection { + try { + val url = resolveJdbcUrl() + val driver = YdbDriver::class.java.name + Class.forName(driver) + return DriverManager.getConnection(url) + } 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) + logger.info("YDB EntityManagerFactory created via JpaUtils") + return entityManagerFactory + } + } + + private fun buildPropertiesFromScope(): MutableMap { + val properties = mutableMapOf() + + properties[AvailableSettings.JAKARTA_JDBC_URL] = resolveJdbcUrl() + properties[AvailableSettings.JAKARTA_JDBC_DRIVER] = YdbDriver::class.java.name + + 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" + } +} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt new file mode 100644 index 00000000..a2571998 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -0,0 +1,81 @@ +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 org.keycloak.provider.EnvironmentDependentProviderFactory +import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.YdbProfile +import tech.ydb.liquibase.database.YdbDatabase +import java.sql.Connection + +class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider(), EnvironmentDependentProviderFactory { + + 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 isSupported(scope: Config.Scope): Boolean = YdbProfile.IS_YDB_PROFILE_ENABLED + + 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 PROVIDER_ID: String = "ydb-liquibase" + 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/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt deleted file mode 100644 index 8b7fc497..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/migration/YdbMigrationManager.kt +++ /dev/null @@ -1,32 +0,0 @@ -package tech.ydb.keycloak.migration - -import liquibase.Liquibase -import liquibase.database.jvm.JdbcConnection -import liquibase.exception.LiquibaseException -import liquibase.resource.ClassLoaderResourceAccessor -import org.jboss.logging.Logger -import java.sql.SQLException -import javax.sql.DataSource - -object YdbMigrationManager { - private const val CHANGELOG_FILE: String = "ydb/db.changelog-master.xml" - - private val logger = Logger.getLogger(YdbMigrationManager::class.java) - - fun migrate(dataSource: DataSource) { - logger.info("Starting YDB migrations using Liquibase...") - - try { - dataSource.connection.use { connection -> - Liquibase(CHANGELOG_FILE, ClassLoaderResourceAccessor(), JdbcConnection(connection)).use { liquibase -> - liquibase.update() - logger.info("YDB migrations completed successfully") - } - } - } catch (e: LiquibaseException) { - logger.error("Failed to execute YDB migrations", e) - - throw SQLException("Failed to execute YDB migrations", e) - } - } -} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 37bf866d..807f96ef 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -40,7 +40,7 @@ class YdbRealmProviderFactory() : RealmProviderFactory, Enviro override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - override fun order(): Int = PROVIDER_PRIORITY + 1 + override fun order(): Int = PROVIDER_PRIORITY private companion object { private const val ID = "ydb-realm-provider-factory" diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt deleted file mode 100644 index d9580ff9..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/EntityManagerUtils.kt +++ /dev/null @@ -1,144 +0,0 @@ -package tech.ydb.keycloak.util - -import com.zaxxer.hikari.HikariDataSource -import jakarta.persistence.EntityManagerFactory -import org.hibernate.boot.registry.StandardServiceRegistryBuilder -import org.hibernate.cfg.AvailableSettings -import org.hibernate.cfg.Configuration -import org.jboss.logging.Logger - -object EntityManagerUtils { - private val logger = Logger.getLogger(EntityManagerUtils::class.java) - - fun createEntityManagerFactory(dataSource: HikariDataSource, showSql: Boolean, formatSql: Boolean): EntityManagerFactory { - logger.info("Creating YDB EntityManagerFactory programmatically") - - val configuration = Configuration() - - configuration.setProperty(AvailableSettings.DIALECT, "tech.ydb.hibernate.dialect.YdbDialect") - configuration.setProperty(AvailableSettings.HBM2DDL_AUTO, "none") - configuration.setProperty(AvailableSettings.SHOW_SQL, showSql) - configuration.setProperty(AvailableSettings.FORMAT_SQL, formatSql) - - configuration.setProperty(AvailableSettings.STATEMENT_BATCH_SIZE, "0") - configuration.setProperty(AvailableSettings.ORDER_INSERTS, "false") - configuration.setProperty(AvailableSettings.ORDER_UPDATES, "false") - - configuration.setProperty(AvailableSettings.DEFAULT_BATCH_FETCH_SIZE, "8") - configuration.setProperty(AvailableSettings.USE_SQL_COMMENTS, "false") - configuration.setProperty("hibernate.query.in_clause_parameter_padding", "true") - configuration.setProperty(AvailableSettings.STATEMENT_FETCH_SIZE, "64") - - addKeycloakEntities(configuration) - - val serviceRegistry = StandardServiceRegistryBuilder() - // TODO use not deprecated instead of DATASOURCE - .applySetting(AvailableSettings.DATASOURCE, dataSource) - .applySettings(configuration.properties) - .build() - - val sessionFactory = configuration.buildSessionFactory(serviceRegistry) - - logger.info("YDB EntityManagerFactory created successfully") - - return sessionFactory.unwrap(EntityManagerFactory::class.java) - } - - fun addKeycloakEntities(configuration: Configuration) { - logger.debug("Adding Keycloak entity classes") - - val entityClasses = listOf( - "org.keycloak.models.jpa.entities.ClientEntity", - "org.keycloak.models.jpa.entities.ClientAttributeEntity", - "org.keycloak.models.jpa.entities.CredentialEntity", - "org.keycloak.models.jpa.entities.RealmEntity", - "org.keycloak.models.jpa.entities.RealmAttributeEntity", - "org.keycloak.models.jpa.entities.RequiredCredentialEntity", - "org.keycloak.models.jpa.entities.ComponentConfigEntity", - "org.keycloak.models.jpa.entities.ComponentEntity", - "org.keycloak.models.jpa.entities.UserFederationProviderEntity", - "org.keycloak.models.jpa.entities.UserFederationMapperEntity", - "org.keycloak.models.jpa.entities.RoleEntity", - "org.keycloak.models.jpa.entities.RoleAttributeEntity", - "org.keycloak.models.jpa.entities.FederatedIdentityEntity", - "org.keycloak.models.jpa.entities.MigrationModelEntity", - "org.keycloak.models.jpa.entities.UserEntity", - "org.keycloak.models.jpa.entities.RealmLocalizationTextsEntity", - "org.keycloak.models.jpa.entities.UserRequiredActionEntity", - "org.keycloak.models.jpa.entities.UserAttributeEntity", - "org.keycloak.models.jpa.entities.UserRoleMappingEntity", - "org.keycloak.models.jpa.entities.IdentityProviderEntity", - "org.keycloak.models.jpa.entities.IdentityProviderMapperEntity", - "org.keycloak.models.jpa.entities.ProtocolMapperEntity", - "org.keycloak.models.jpa.entities.UserConsentEntity", - "org.keycloak.models.jpa.entities.UserConsentClientScopeEntity", - "org.keycloak.models.jpa.entities.AuthenticationFlowEntity", - "org.keycloak.models.jpa.entities.AuthenticationExecutionEntity", - "org.keycloak.models.jpa.entities.AuthenticatorConfigEntity", - "org.keycloak.models.jpa.entities.RequiredActionProviderEntity", - "org.keycloak.models.jpa.session.PersistentUserSessionEntity", - "org.keycloak.models.jpa.session.PersistentClientSessionEntity", - "org.keycloak.models.jpa.entities.RevokedTokenEntity", - "org.keycloak.models.jpa.entities.GroupEntity", - "org.keycloak.models.jpa.entities.GroupAttributeEntity", - "org.keycloak.models.jpa.entities.GroupRoleMappingEntity", - "org.keycloak.models.jpa.entities.UserGroupMembershipEntity", - "org.keycloak.models.jpa.entities.ClientScopeEntity", - "org.keycloak.models.jpa.entities.ClientScopeAttributeEntity", - "org.keycloak.models.jpa.entities.ClientScopeRoleMappingEntity", - "org.keycloak.models.jpa.entities.ClientScopeClientMappingEntity", - "org.keycloak.models.jpa.entities.DefaultClientScopeRealmMappingEntity", - "org.keycloak.models.jpa.entities.ClientInitialAccessEntity", - - // Events - "org.keycloak.events.jpa.EventEntity", - "org.keycloak.events.jpa.AdminEventEntity", - - // Authorization - "org.keycloak.authorization.jpa.entities.ResourceServerEntity", - "org.keycloak.authorization.jpa.entities.ResourceEntity", - "org.keycloak.authorization.jpa.entities.ScopeEntity", - "org.keycloak.authorization.jpa.entities.PolicyEntity", - "org.keycloak.authorization.jpa.entities.PermissionTicketEntity", - "org.keycloak.authorization.jpa.entities.ResourceAttributeEntity", - - // Federated storage - "org.keycloak.storage.jpa.entity.BrokerLinkEntity", - "org.keycloak.storage.jpa.entity.FederatedUser", - "org.keycloak.storage.jpa.entity.FederatedUserAttributeEntity", - "org.keycloak.storage.jpa.entity.FederatedUserConsentEntity", - "org.keycloak.storage.jpa.entity.FederatedUserConsentClientScopeEntity", - "org.keycloak.storage.jpa.entity.FederatedUserCredentialEntity", - "org.keycloak.storage.jpa.entity.FederatedUserGroupMembershipEntity", - "org.keycloak.storage.jpa.entity.FederatedUserRequiredActionEntity", - "org.keycloak.storage.jpa.entity.FederatedUserRoleMappingEntity", - - // Organization - "org.keycloak.models.jpa.entities.OrganizationEntity", - "org.keycloak.models.jpa.entities.OrganizationDomainEntity", - - // Server config - "org.keycloak.storage.configuration.jpa.entity.ServerConfigEntity", - - // Workflows - "org.keycloak.models.workflow.WorkflowStateEntity" - ) - - var addedCount = 0 - var failedCount = 0 - - entityClasses.forEach { className -> - try { - val clazz = Class.forName(className) - configuration.addAnnotatedClass(clazz) - addedCount++ - } catch (e: ClassNotFoundException) { - logger.warn("Entity class not found: $className", e) - failedCount++ - } - } - - logger.info("Added $addedCount entity classes, $failedCount not found") - } - -} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt deleted file mode 100644 index 7d966fa3..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/util/MigrationUtils.kt +++ /dev/null @@ -1,20 +0,0 @@ -package tech.ydb.keycloak.util - -import com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource - -fun hikariDataSource( - jdbcUrl: String?, - poolSize: Int, -): HikariDataSource = HikariDataSource(hikariConfig(jdbcUrl, poolSize)) - -fun hikariConfig( - jdbcUrl: String?, - poolSize: Int, -): HikariConfig = HikariConfig().apply {// todo Review how to create connections correctly. - this.jdbcUrl = jdbcUrl - this.driverClassName = "tech.ydb.jdbc.YdbDriver" - this.maximumPoolSize = poolSize - this.poolName = "YDB-HikariPool" - this.isAutoCommit = false // todo review -} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory index 334d3371..9348d601 100644 --- a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory @@ -1 +1 @@ -tech.ydb.keycloak.connection.DefaultYdbConnectionProviderFactory +tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory b/keycloak-ydb-extension/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/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.liquibase.YdbLiquibaseConnectionProvider From 5d7dd1b8f3c2997f0bc59771f167025d9a6d36d5 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:35:15 +0300 Subject: [PATCH 055/120] feat: Add migrations to keycloak started successfully with ydb. Format old migrations --- .../2025-12-17-21-07-create-realm-table.sql | 2 +- ...17-21-29-create-realm-attributes-table.sql | 2 +- ...reate-realm-required-credentials-table.sql | 2 +- .../2026-02-01-15-04-keycloak-role-table.sql | 18 ++ .../2026-02-01-16-04-composite-role-table.sql | 9 + .../2026-02-03-2-16-role-attrubute-table.sql | 11 + ...-06-06-00-create-migration-model-table.sql | 8 + ...6-21-24-create-client-attributes-table.sql | 10 + ...02-06-21-34-create-redirect-uris-table.sql | 9 + ...06-21-35-create-protocol-mappers-table.sql | 15 ++ ...38-create-protocol-mapper-config-table.sql | 9 + ...02-07-02-06-create-scope-mapping-table.sql | 9 + ...6-02-07-03-53-create-web-origins-table.sql | 9 + ...-02-07-15-38-create-client-scope-table.sql | 14 ++ ...0-create-client-scope-attributes-table.sql | 10 + ...15-51-create-client-scope-client-table.sql | 10 + ...create-client-scope-role-mapping-table.sql | 11 + ...6-02-07-16-00-create-user-entity-table.sql | 22 ++ ...7-16-02-create-user-role-mapping-table.sql | 10 + ...7-16-05-create-identity-provider-table.sql | 25 ++ ...-create-identity-provider-config-table.sql | 10 + ...-create-identity-provider-mapper-table.sql | 12 + ...26-02-07-16-09-create-credential-table.sql | 17 ++ ...2-07-16-11-create-user-attribute-table.sql | 19 ++ ...02-07-16-15-create-revoked-token-table.sql | 8 + ...create-client-auth-flow-bindings-table.sql | 12 + ...create-client-node-registrations-table.sql | 11 + ...6-24-create-user-required-action-table.sql | 9 + ...26-create-offline-client-session-table.sql | 16 ++ ...6-27-create-offline-user-session-table.sql | 18 ++ ...-37-create-user-group-membership-table.sql | 12 + ...2-07-16-40-create-keycloak-group-table.sql | 16 ++ .../2026-02-07-16-53-create-org-table.sql | 17 ++ ...26-02-07-16-55-create-org-domain-table.sql | 10 + ...-02-07-17-04-create-user-consent-table.sql | 18 ++ ...create-user-consent-client-scope-table.sql | 11 + ...-17-08-create-federated-identity-table.sql | 16 ++ ...07-17-09-create-fed-user-consent-table.sql | 19 ++ ...create-fed-user-consent-cl-scope-table.sql | 11 + ...17-11-create-fed-user-credential-table.sql | 20 ++ ...create-fed-user-group-membership-table.sql | 15 ++ ...-create-fed-user-required-action-table.sql | 13 + ...-13-create-fed-user-role-mapping-table.sql | 15 ++ ...2-07-17-14-create-federated-user-table.sql | 11 + ...6-02-07-17-15-create-broker-link-table.sql | 17 ++ ...-17-16-create-fed-user-attribute-table.sql | 20 ++ ...-07-17-17-create-group-attribute-table.sql | 12 + ...-17-18-create-group-role-mapping-table.sql | 11 + ...-08-19-53-create-resource-server-table.sql | 9 + ...54-create-resource-server-policy-table.sql | 19 ++ ...-create-resource-server-resource-table.sql | 18 ++ ...-19-57-create-resource-attribute-table.sql | 12 + ...-08-19-58-create-resource-policy-table.sql | 11 + ...-59-create-resource-server-scope-table.sql | 14 ++ ...2-08-20-00-create-resource-scope-table.sql | 11 + ...eate-resource-server-perm-ticket-table.sql | 26 ++ ...02-08-20-02-create-resource-uris-table.sql | 10 + .../resources/ydb/db.changelog-master.xml | 223 +++++++++++++++++- 58 files changed, 960 insertions(+), 4 deletions(-) create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql index 0ea8a8ea..2d04eeb8 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql @@ -57,4 +57,4 @@ CREATE TABLE IF NOT EXISTS REALM 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/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql index f0412189..e4ce543f 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql @@ -5,5 +5,5 @@ CREATE TABLE IF NOT EXISTS REALM_ATTRIBUTE VALUE Utf8, INDEX realm_attributes_idx_realm_id GLOBAL ON (REALM_ID), - PRIMARY KEY (NAME, REALM_ID), + PRIMARY KEY (NAME, REALM_ID) ); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql index 6b6a2d3b..cbdc40f3 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql @@ -6,5 +6,5 @@ create table IF NOT EXISTS REALM_REQUIRED_CREDENTIAL INPUT Bool not null default false, SECRET Bool not null default false, - primary key (REALM_ID, TYPE) + PRIMARY KEY (REALM_ID, TYPE) ); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql new file mode 100644 index 00000000..da00e0c0 --- /dev/null +++ b/keycloak-ydb-extension/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`), + +-- foreign key to REALM + INDEX fk_6vyqfe4cn4wlq8r6kt5vdsj5c GLOBAL ON (`REALM`), + INDEX `UK_J3RWUVD56ONTGSUHOGM184WW2-2` GLOBAL UNIQUE ON (`NAME`, `CLIENT_REALM_CONSTRAINT`), + PRIMARY KEY (`ID`) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql new file mode 100644 index 00000000..2b10487a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql @@ -0,0 +1,9 @@ +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), + PRIMARY KEY (COMPOSITE, CHILD_ROLE) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql new file mode 100644 index 00000000..1b8a6293 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS MIGRATION_MODEL +( + `ID` Utf8 NOT NULL, + `VERSION` Utf8, + `UPDATE_TIME` Int64 NOT NULL DEFAULT 0, + + PRIMARY KEY (`ID`) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql new file mode 100644 index 00000000..d164d690 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql @@ -0,0 +1,10 @@ +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`, SUBSTRING(`VALUE`, 1, 255)), +-- not implemented in ydb... + PRIMARY KEY (CLIENT_ID, NAME) +); diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql new file mode 100644 index 00000000..dfd04c04 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql @@ -0,0 +1,11 @@ +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), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (SCOPE_ID, ROLE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql new file mode 100644 index 00000000..93711a53 --- /dev/null +++ b/keycloak-ydb-extension/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 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/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql new file mode 100644 index 00000000..cf21289b --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql @@ -0,0 +1,10 @@ +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), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ROLE_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql new file mode 100644 index 00000000..4d48924c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_CONFIG +( + `IDENTITY_PROVIDER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_idp_config_provider GLOBAL ON (IDENTITY_PROVIDER_ID), +-- 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/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql new file mode 100644 index 00000000..20ab6df2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS CLIENT_AUTH_FLOW_BINDINGS +( + `CLIENT_ID` Utf8 NOT NULL, + `FLOW_ID` Utf8, + `BINDING_NAME` Utf8 NOT NULL, + + INDEX idx_cl_auth_flow_client GLOBAL ON (CLIENT_ID), + INDEX idx_cl_auth_flow_flow GLOBAL ON (FLOW_ID), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), +-- FOREIGN KEY (FLOW_ID) REFERENCES AUTHENTICATION_FLOW (ID), + PRIMARY KEY (CLIENT_ID, BINDING_NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql new file mode 100644 index 00000000..22134d31 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS CLIENT_NODE_REGISTRATIONS +( + `CLIENT_ID` Utf8 NOT NULL, + `VALUE` Int32, + `NAME` Utf8 NOT NULL, + + INDEX idx_cl_node_reg_client GLOBAL ON (CLIENT_ID), + INDEX idx_cl_node_reg_name GLOBAL ON (NAME), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (CLIENT_ID, NAME) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql new file mode 100644 index 00000000..8a09167d --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql @@ -0,0 +1,16 @@ +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_offl_client_sess_client GLOBAL ON (CLIENT_ID), + INDEX idx_offl_client_sess_user GLOBAL ON (USER_SESSION_ID), + INDEX idx_offl_client_sess_ext_client GLOBAL ON (EXTERNAL_CLIENT_ID), + 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/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql new file mode 100644 index 00000000..2c782e97 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql @@ -0,0 +1,18 @@ +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), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (USER_SESSION_ID, OFFLINE_FLAG) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql new file mode 100644 index 00000000..6eff3d61 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql @@ -0,0 +1,12 @@ +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), + INDEX idx_user_group_group GLOBAL ON (GROUP_ID), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (GROUP_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql new file mode 100644 index 00000000..dc48ff3e --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql @@ -0,0 +1,16 @@ +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, + + INDEX idx_keycloak_group_parent GLOBAL ON (PARENT_GROUP), + INDEX idx_keycloak_group_realm GLOBAL ON (REALM_ID), +-- CONSTRAINT `SIBLING_NAMES` GLOBAL UNIQUE ON (REALM_ID, PARENT_GROUP, NAME), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (PARENT_GROUP) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql new file mode 100644 index 00000000..63cc6da8 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql @@ -0,0 +1,17 @@ +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), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql new file mode 100644 index 00000000..5bf0d047 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql @@ -0,0 +1,18 @@ +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), + INDEX idx_user_consent_client GLOBAL ON (CLIENT_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), +-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql new file mode 100644 index 00000000..2000c865 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql @@ -0,0 +1,11 @@ +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), +-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql new file mode 100644 index 00000000..de91798a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql @@ -0,0 +1,16 @@ +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), + INDEX idx_fedidentity_provider GLOBAL ON (IDENTITY_PROVIDER), +-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql new file mode 100644 index 00000000..fef14ad0 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql @@ -0,0 +1,19 @@ +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), + INDEX idx_fed_consent_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/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql new file mode 100644 index 00000000..1f43b456 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FED_USER_CONSENT_CL_SCOPE +( + `USER_CONSENT_ID` Utf8 NOT NULL, + `SCOPE_ID` Utf8 NOT NULL, + + INDEX idx_fed_consent_cl_scope_consent GLOBAL ON (USER_CONSENT_ID), + INDEX idx_fed_consent_cl_scope_scope GLOBAL ON (SCOPE_ID), +-- FOREIGN KEY (USER_CONSENT_ID) REFERENCES FED_USER_CONSENT (ID), +-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), + PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql new file mode 100644 index 00000000..9139ecf6 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql @@ -0,0 +1,20 @@ +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), + INDEX idx_fed_credential_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/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql new file mode 100644 index 00000000..f6cf32f2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql @@ -0,0 +1,15 @@ +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), + INDEX idx_fed_group_membership_group GLOBAL ON (GROUP_ID), + INDEX idx_fed_group_membership_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), + PRIMARY KEY (GROUP_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql new file mode 100644 index 00000000..5ebf972f --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql @@ -0,0 +1,13 @@ +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), + INDEX idx_fed_req_action_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (REQUIRED_ACTION, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql new file mode 100644 index 00000000..dd8c6509 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql @@ -0,0 +1,15 @@ +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), + INDEX idx_fed_role_mapping_role GLOBAL ON (ROLE_ID), + INDEX idx_fed_role_mapping_realm GLOBAL ON (REALM_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ROLE_ID, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql new file mode 100644 index 00000000..45171a97 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS FEDERATED_USER +( + `ID` Utf8 NOT NULL, + `STORAGE_PROVIDER_ID` Utf8, + `REALM_ID` Utf8 NOT NULL, + + INDEX idx_federated_user_realm GLOBAL ON (REALM_ID), + INDEX idx_federated_user_storage GLOBAL ON (STORAGE_PROVIDER_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql new file mode 100644 index 00000000..9468c5ee --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql @@ -0,0 +1,17 @@ +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, + + INDEX idx_broker_link_realm GLOBAL ON (REALM_ID), + INDEX idx_broker_link_user GLOBAL ON (USER_ID), + INDEX idx_broker_link_provider GLOBAL ON (IDENTITY_PROVIDER), + INDEX idx_broker_link_broker_user GLOBAL ON (BROKER_USER_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql new file mode 100644 index 00000000..6023a20c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql @@ -0,0 +1,20 @@ +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), + INDEX idx_fed_user_attr_realm GLOBAL ON (REALM_ID), + INDEX idx_fed_user_attr_user GLOBAL ON (USER_ID), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql new file mode 100644 index 00000000..12a7da1c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql @@ -0,0 +1,11 @@ +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), + INDEX idx_group_role_mapp_role GLOBAL ON (ROLE_ID), +-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), +-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), + PRIMARY KEY (ROLE_ID, GROUP_ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql new file mode 100644 index 00000000..4f8b5dc2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql @@ -0,0 +1,19 @@ +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), + INDEX idx_res_serv_pol_type GLOBAL ON (TYPE), + INDEX idx_res_serv_pol_owner GLOBAL ON (OWNER), +-- 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/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql new file mode 100644 index 00000000..ae28bc93 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql @@ -0,0 +1,18 @@ +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), + INDEX idx_res_srv_res_owner GLOBAL ON (OWNER), +-- 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/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql new file mode 100644 index 00000000..75b054c2 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql @@ -0,0 +1,12 @@ +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, + + INDEX idx_resource_attr_resource GLOBAL ON (RESOURCE_ID), + INDEX idx_resource_attr_name_value GLOBAL ON (NAME, VALUE), +-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql new file mode 100644 index 00000000..0f57152a --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql @@ -0,0 +1,11 @@ +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), + INDEX idx_res_policy_resource GLOBAL ON (RESOURCE_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/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql new file mode 100644 index 00000000..3ddeaded --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql @@ -0,0 +1,11 @@ +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), + INDEX idx_res_scope_resource GLOBAL ON (RESOURCE_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/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql new file mode 100644 index 00000000..28c8dbd1 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql @@ -0,0 +1,26 @@ +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), + INDEX idx_perm_ticket_resource GLOBAL ON (RESOURCE_ID), + INDEX idx_perm_ticket_resource_server GLOBAL ON (RESOURCE_SERVER_ID), + INDEX idx_perm_ticket_policy GLOBAL ON (POLICY_ID), + INDEX idx_perm_ticket_scope GLOBAL ON (SCOPE_ID), +-- 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/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql new file mode 100644 index 00000000..46237b8c --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS RESOURCE_URIS +( + `RESOURCE_ID` Utf8 NOT NULL, + `VALUE` Utf8 NOT NULL, + + INDEX idx_resource_uris_resource GLOBAL ON (RESOURCE_ID), + INDEX idx_resource_uris_value GLOBAL ON (VALUE), +-- 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/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml index 1112dc98..7526d5fa 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml +++ b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml @@ -4,5 +4,226 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 9144b91b22a7a897ae17158bf3db686c3ccc7393 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:36:48 +0300 Subject: [PATCH 056/120] feat: Rewrite some functions, because ydb does not support FOR UPDATE --- .../ydb/keycloak/realm/YdbRealmProvider.kt | 104 +++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt index 47ad8878..23c07940 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -1,10 +1,110 @@ 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.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.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( - session: KeycloakSession, + private val keycloakSession: KeycloakSession, entityManager: EntityManager, -) : JpaRealmProvider(session, entityManager, null, null) + 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 + } +} From 3300c85239bb6feb4411f7259c05b221aff92f77 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 10 Feb 2026 18:37:35 +0300 Subject: [PATCH 057/120] feat: Add YdbClientProviderFactory to use YdbRealmProvider in creation of ClientProviderFactory --- .../client/YdbClientProviderFactory.kt | 68 +++++++++++++++++++ .../org.keycloak.models.ClientProviderFactory | 1 + 2 files changed, 69 insertions(+) create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt new file mode 100644 index 00000000..6781fac9 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -0,0 +1,68 @@ +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.ProviderPriority.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") + if (searchableAttrsArr == null) { + val s = System.getProperty("keycloak.client.searchableAttributes") + searchableAttrsArr = s?.split("\\s*,\\s*".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + } + 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 + + 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/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory new file mode 100644 index 00000000..07dfd04f --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.client.YdbClientProviderFactory \ No newline at end of file From 4735ca16320c4c8c96ff16403a4b4ad9ea987e62 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:32:16 +0300 Subject: [PATCH 058/120] feat: Use hibernate-v7 dialect --- keycloak-ydb-extension/pom.xml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index 560fa287..c5b88e2f 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -23,7 +23,7 @@ 2.3.20 7.0.2 - 1.5.1 + 0.9.1 @@ -200,11 +200,8 @@ tech.ydb.dialects - hibernate-ydb-dialect - ${hibernate.ydb.dialect.version} - - - + hibernate-ydb-dialect-v7 + ${hibernate-v7.ydb.dialect.version} From a348c3e4bade128a15d466a215028896fa9502d9 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:32:56 +0300 Subject: [PATCH 059/120] refactor: Make code more kotlin style --- .../tech/ydb/keycloak/client/YdbClientProviderFactory.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt index 6781fac9..2b90b4ee 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -20,10 +20,10 @@ class YdbClientProviderFactory : JpaClientProviderFactory() { private lateinit var clientSearchableAttributes: Set override fun init(config: Config.Scope) { - var searchableAttrsArr = config.getArray("searchableAttributes") + var searchableAttrsArr = config.getArray("searchableAttributes")?.toList() if (searchableAttrsArr == null) { val s = System.getProperty("keycloak.client.searchableAttributes") - searchableAttrsArr = s?.split("\\s*,\\s*".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + searchableAttrsArr = s?.split("\\s*,\\s*".toRegex()) } val s = HashSet(REQUIRED_SEARCHABLE_ATTRIBUTES) if (searchableAttrsArr != null) { From 3203a2a7baec2864bee202bfdbc023ff7fb5de42 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:34:09 +0300 Subject: [PATCH 060/120] feat: Create ClientScopeProviderFactory implementation to return YdbRealmProvider instead of JpaRealmProvider --- .../client/YdbClientScopeProviderFactory.kt | 19 +++++++++++++++++++ ...keycloak.models.ClientScopeProviderFactory | 1 + 2 files changed, 20 insertions(+) create mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt create mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt new file mode 100644 index 00000000..71edb3a0 --- /dev/null +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt @@ -0,0 +1,19 @@ +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.ProviderPriority.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 { + return PROVIDER_PRIORITY + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory new file mode 100644 index 00000000..ec4888dd --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.client.YdbClientScopeProviderFactory \ No newline at end of file From 826d94687d5a0b48504e5a1bf12e5c5406c179a5 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:35:04 +0300 Subject: [PATCH 061/120] feat: Implement removeClientScope without PESSIMISTIC_WRITE because ydb does not support FOR UPDATE --- .../ydb/keycloak/realm/YdbRealmProvider.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt index 23c07940..c5d8ec2c 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt @@ -4,11 +4,14 @@ 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 @@ -107,4 +110,34 @@ class YdbRealmProvider( 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 + } } From 55c3e9be0e396c55ecfc8c2aa42e0934ee96958a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Feb 2026 15:35:30 +0300 Subject: [PATCH 062/120] chore: Add more migrations to support realm removing --- ...02-15-13-45-create-policy-config-table.sql | 11 ++++++++++ ...5-15-18-create-idp-mapper-config-table.sql | 11 ++++++++++ ...-21-create-client-initial-access-table.sql | 13 ++++++++++++ ...-create-user-federation-provider-table.sql | 17 +++++++++++++++ ...24-create-user-federation-config-table.sql | 11 ++++++++++ ...25-create-user-federation-mapper-table.sql | 15 +++++++++++++ ...te-user-federation-mapper-config-table.sql | 11 ++++++++++ .../resources/ydb/db.changelog-master.xml | 21 +++++++++++++++++++ 8 files changed, 110 insertions(+) create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql create mode 100644 keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql new file mode 100644 index 00000000..217936f1 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS POLICY_CONFIG +( + `POLICY_ID` Utf8 NOT NULL, + `NAME` Utf8 NOT NULL, + `VALUE` Utf8, + + INDEX idx_policy_config_policy GLOBAL ON (POLICY_ID), + INDEX idx_policy_config_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql new file mode 100644 index 00000000..fe2d5dac --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS IDP_MAPPER_CONFIG +( + `IDP_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_idp_mapper_config_mapper GLOBAL ON (IDP_MAPPER_ID), + INDEX idx_idp_mapper_config_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql b/keycloak-ydb-extension/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/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/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql new file mode 100644 index 00000000..dd0254f4 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql @@ -0,0 +1,17 @@ +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), + INDEX idx_usr_fed_prv_name GLOBAL ON (PROVIDER_NAME), + INDEX idx_usr_fed_prv_priority GLOBAL ON (PRIORITY), +-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql new file mode 100644 index 00000000..f8ca41a0 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_CONFIG +( + `USER_FEDERATION_PROVIDER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_user_fed_config_provider GLOBAL ON (USER_FEDERATION_PROVIDER_ID), + INDEX idx_user_fed_config_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql new file mode 100644 index 00000000..c532d015 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql @@ -0,0 +1,15 @@ +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), + INDEX idx_usr_fed_map_type GLOBAL ON (FEDERATION_MAPPER_TYPE), +-- 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/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql new file mode 100644 index 00000000..7d53e774 --- /dev/null +++ b/keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER_CONFIG +( + `USER_FEDERATION_MAPPER_ID` Utf8 NOT NULL, + `VALUE` Utf8, + `NAME` Utf8 NOT NULL, + + INDEX idx_usr_fed_map_cfg_mapper GLOBAL ON (USER_FEDERATION_MAPPER_ID), + INDEX idx_usr_fed_map_cfg_name GLOBAL ON (NAME), +-- 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/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml index 7526d5fa..c3e4f154 100644 --- a/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml +++ b/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml @@ -226,4 +226,25 @@ + + + + + + + + + + + + + + + + + + + + + From 5a31c999ac71d495d8a327d47b5b1feafdaf24c4 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 16 Feb 2026 18:20:46 +0300 Subject: [PATCH 063/120] feat: remove features-disabled from docker-compose.yml --- keycloak-ydb-extension/docker/docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 6f1b6d27..3951cbef 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -55,7 +55,6 @@ services: command: > -v start-dev --cache=local - --features-disabled=authorization,admin-fine-grained-authz,organization ports: - 9090:8080 networks: From 3fc1e80420643c4062374daa4fceea0580916039 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 16 Feb 2026 18:22:45 +0300 Subject: [PATCH 064/120] refactor: Write keycloak correctly. Make error in run-keycloack-with-ydb.sh in english --- keycloak-ydb-extension/README.md | 2 +- .../{run-keycloack-with-ydb.sh => run-keycloak-with-ydb.sh} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename keycloak-ydb-extension/{run-keycloack-with-ydb.sh => run-keycloak-with-ydb.sh} (67%) diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 70b83059..745f0716 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -28,7 +28,7 @@ Alternative: you can set `KC_DB_URL` instead of `KC_YDB_URL`; the extension will From the project root: ```bash -./run-keycloack-with-ydb.sh +./run-keycloak-with-ydb.sh ``` This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. \ No newline at end of file diff --git a/keycloak-ydb-extension/run-keycloack-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh similarity index 67% rename from keycloak-ydb-extension/run-keycloack-with-ydb.sh rename to keycloak-ydb-extension/run-keycloak-with-ydb.sh index 2ec18c34..1dc0e82d 100644 --- a/keycloak-ydb-extension/run-keycloack-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -7,8 +7,8 @@ mkdir -p docker/providers JAR_FILE="target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" if [ ! -f "$JAR_FILE" ]; then - echo "Ошибка: Файл $JAR_FILE не найден!" - echo "Сборка проекта, возможно, завершилась неудачно." + echo "Error: File $JAR_FILE not found!" + echo "The project build may have failed." exit 1 fi From 3f089cd70fde768fa109fce71b575f8ae9ab410a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 25 Feb 2026 23:59:50 +0300 Subject: [PATCH 065/120] refactor: Remove redundant IS_YDB_PROFILE_ENABLED --- keycloak-ydb-extension/README.md | 5 ++--- keycloak-ydb-extension/docker/docker-compose.yml | 1 - .../kotlin/tech/ydb/keycloak/config/YdbProfile.kt | 9 --------- .../connection/YdbConnectionProviderFactoryImpl.kt | 12 +----------- .../liquibase/YdbLiquibaseConnectionProvider.kt | 6 +----- .../ydb/keycloak/realm/YdbRealmProviderFactory.kt | 6 +----- 6 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 745f0716..3cf05900 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -11,12 +11,11 @@ When using YDB you must avoid giving the YDB URL to Keycloak’s default datasou | Variable | Value | Purpose | |----------|--------|---------| | `KC_DB` | `dev-file` | Built-in datasource uses dev-file; it never sees the YDB URL. | -| `KC_YDB_URL` | `jdbc:ydb:grpc://host:2136/database` | JDBC URL used by this extension. | -| `KC_COMMUNITY_DATASTORE_YDB_ENABLED` | `true` | Enables this extension’s JPA provider. | +| `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). | -Alternative: you can set `KC_DB_URL` instead of `KC_YDB_URL`; the extension will use it when the YDB profile is enabled. Then still set `KC_DB=dev-file` so the default pool is not created with that URL. +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. ## Getting started diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 3951cbef..b375052e 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -44,7 +44,6 @@ services: # YDB JDBC URL (config key ydbJdbcUrl / ydb-url; env KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL). - KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local - KC_SPI_CONNECTIONS_JPA_SHOW_SQL=true - - KC_COMMUNITY_DATASTORE_YDB_ENABLED=true # Disable default JPA and Liquibase providers so H2 is not used (only YDB is used). - KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED=false - KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED=false diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt deleted file mode 100644 index 170f3bc2..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/YdbProfile.kt +++ /dev/null @@ -1,9 +0,0 @@ -package tech.ydb.keycloak.config - -object YdbProfile { - private const val ENV_YDB_PROFILE_ENABLED: String = "KC_COMMUNITY_DATASTORE_YDB_ENABLED" - private const val PROP_YDB_PROFILE_ENABLED: String = "kc.community.datastore.ydb.enabled" - - val IS_YDB_PROFILE_ENABLED = System.getenv(ENV_YDB_PROFILE_ENABLED).toBoolean() - || System.getProperty(PROP_YDB_PROFILE_ENABLED).toBoolean() -} diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt index c02fc94d..b69193b7 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -22,12 +22,10 @@ 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.EnvironmentDependentProviderFactory import org.keycloak.provider.ServerInfoAwareProviderFactory import tech.ydb.hibernate.dialect.YdbDialect import tech.ydb.jdbc.YdbDriver import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY -import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl.Companion.MigrationStrategy.* import java.io.File import java.sql.Connection @@ -35,9 +33,7 @@ import java.sql.DriverManager import java.util.* import kotlin.properties.Delegates -class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, - ServerInfoAwareProviderFactory, - EnvironmentDependentProviderFactory { +class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInfoAwareProviderFactory { private val logger: Logger = Logger.getLogger(YdbConnectionProviderFactoryImpl::class.java) @@ -65,10 +61,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, } override fun init(scope: Config.Scope) { - if (!isSupported(scope)) { - logger.debug("YDB JPA disabled (profile not enabled), skipping init") - return - } config = scope } @@ -156,8 +148,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, override fun order(): Int = PROVIDER_PRIORITY - override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - override fun getConnection(): Connection { try { val url = resolveJdbcUrl() diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt index a2571998..77110bc8 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -9,13 +9,11 @@ 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 org.keycloak.provider.EnvironmentDependentProviderFactory import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY -import tech.ydb.keycloak.config.YdbProfile import tech.ydb.liquibase.database.YdbDatabase import java.sql.Connection -class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider(), EnvironmentDependentProviderFactory { +class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider() { private var indexCreationThreshold: Long = DEFAULT_INDEX_CREATION_THRESHOLD @@ -28,8 +26,6 @@ class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider(), Env override fun order(): Int = PROVIDER_PRIORITY - override fun isSupported(scope: Config.Scope): Boolean = YdbProfile.IS_YDB_PROFILE_ENABLED - override fun getLiquibase(connection: Connection, defaultSchema: String?): KeycloakLiquibase { val database = newYdbDatabase(connection) if (!defaultSchema.isNullOrBlank()) { diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 807f96ef..2020f9ab 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -5,12 +5,10 @@ import org.keycloak.Config import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.models.RealmProviderFactory -import org.keycloak.provider.EnvironmentDependentProviderFactory import org.keycloak.connections.jpa.JpaConnectionProvider import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY -import tech.ydb.keycloak.config.YdbProfile.IS_YDB_PROFILE_ENABLED -class YdbRealmProviderFactory() : RealmProviderFactory, EnvironmentDependentProviderFactory { +class YdbRealmProviderFactory() : RealmProviderFactory { private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java) @@ -38,8 +36,6 @@ class YdbRealmProviderFactory() : RealmProviderFactory, Enviro override fun getId(): String = ID - override fun isSupported(scope: Config.Scope): Boolean = IS_YDB_PROFILE_ENABLED - override fun order(): Int = PROVIDER_PRIORITY private companion object { From d61f6b3497fdc18b1485ef08e83eb5481ce37bd0 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:03:41 +0300 Subject: [PATCH 066/120] refactor: Deleted redundant files --- .../connection/YdbConnectionProvider.kt | 5 ----- .../connection/YdbConnectionProviderFactory.kt | 5 ----- .../ydb/keycloak/connection/YdbConnectionSpi.kt | 17 ----------------- .../META-INF/services/org.keycloak.provider.Spi | 1 - 4 files changed, 28 deletions(-) delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt delete mode 100644 keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt delete mode 100644 keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt deleted file mode 100644 index c4de0290..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package tech.ydb.keycloak.connection - -import org.keycloak.provider.Provider - -interface YdbConnectionProvider : Provider diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt deleted file mode 100644 index c7ba02de..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactory.kt +++ /dev/null @@ -1,5 +0,0 @@ -package tech.ydb.keycloak.connection - -import org.keycloak.provider.ProviderFactory - -interface YdbConnectionProviderFactory : ProviderFactory diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt deleted file mode 100644 index 1e7e341e..00000000 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionSpi.kt +++ /dev/null @@ -1,17 +0,0 @@ -package tech.ydb.keycloak.connection - -import org.keycloak.provider.Spi - -class YdbConnectionSpi : Spi { - override fun isInternal(): Boolean = true - - override fun getName() = NAME - - override fun getProviderClass() = YdbConnectionProvider::class.java - - override fun getProviderFactoryClass() = YdbConnectionProviderFactory::class.java - - companion object { - private const val NAME: String = "ydbConnection" - } -} diff --git a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi deleted file mode 100644 index 9321db82..00000000 --- a/keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ /dev/null @@ -1 +0,0 @@ -tech.ydb.keycloak.connection.YdbConnectionSpi \ No newline at end of file From 4182df3d0b4f70f42cb8bb779a429eb54c22dd02 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:08:45 +0300 Subject: [PATCH 067/120] refactor: Make PROVIDER_PRIORITY bigger to show that only YDB factories are used --- .../main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt index 7abe5c15..f8f17425 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt @@ -1,6 +1,5 @@ package tech.ydb.keycloak.config object ProviderPriority { - // more than in quarkus provider factories - const val PROVIDER_PRIORITY = 200 + const val PROVIDER_PRIORITY = Int.MAX_VALUE } From fb27f4a4e1a0a28052212ba3dd01c9cb72a53e95 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:27:31 +0300 Subject: [PATCH 068/120] refactor: Use keycloak.conf file to pass all configs --- keycloak-ydb-extension/README.md | 3 ++- .../docker/conf/keycloak.conf | 18 ++++++++++++++++++ .../docker/docker-compose.yml | 12 +----------- .../YdbConnectionProviderFactoryImpl.kt | 3 +-- 4 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 keycloak-ydb-extension/docker/conf/keycloak.conf diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 3cf05900..5f8b69f5 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -30,4 +30,5 @@ From the project root: ./run-keycloak-with-ydb.sh ``` -This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`. \ No newline at end of file +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/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf new file mode 100644 index 00000000..cc9f0ea3 --- /dev/null +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -0,0 +1,18 @@ +# 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-default-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local +spi-connections-jpa-default-show-sql=true + +# YDB Liquibase provider: index creation threshold (optional, default 300000) +# 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 + +# --- Dev mode admin (optional; for start-dev) --- +keycloak-admin=admin +keycloak-admin-password=admin diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index b375052e..08147aba 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -39,17 +39,7 @@ services: image: quay.io/keycloak/keycloak:26.4.7 volumes: - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar - environment: - # Use dev-file for the built-in datasource so it never sees the YDB URL (no "driver does not support URL" error). - # YDB JDBC URL (config key ydbJdbcUrl / ydb-url; env KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL). - - KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL=jdbc:ydb:grpc://ydb:2136/local - - KC_SPI_CONNECTIONS_JPA_SHOW_SQL=true - # Disable default JPA and Liquibase providers so H2 is not used (only YDB is used). - - KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED=false - - KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED=false - # Admin - - KEYCLOAK_ADMIN=admin - - KEYCLOAK_ADMIN_PASSWORD=admin + - ./conf/keycloak.conf:/opt/keycloak/conf/keycloak.conf entrypoint: /opt/keycloak/bin/kc.sh command: > -v start-dev diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt index b69193b7..8278153d 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -78,9 +78,8 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) } } - // TODO: FIND OUT HOW IT SMTH LIKE spi.connections-jpa.default.ydb-jdbc-url private fun resolveJdbcUrl(): String = requireNotNull(config["ydbJdbcUrl"]) { - " YDB JDBC URL is required. Set env variable KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL= " + "YDB JDBC URL is required" } private fun createOrUpdateSchema( From 1dd62daa6e504366cd4fbb731cc71df877d5e48f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:34:23 +0300 Subject: [PATCH 069/120] refactor: Set "ydb" id in all providers --- keycloak-ydb-extension/docker/conf/keycloak.conf | 4 ++-- .../ydb/keycloak/client/YdbClientProviderFactory.kt | 5 ++++- .../keycloak/client/YdbClientScopeProviderFactory.kt | 11 ++++++----- .../config/{ProviderPriority.kt => ProviderConfig.kt} | 3 ++- .../connection/YdbConnectionProviderFactoryImpl.kt | 7 ++++--- .../liquibase/YdbLiquibaseConnectionProvider.kt | 4 ++-- .../ydb/keycloak/realm/YdbRealmProviderFactory.kt | 9 +++------ 7 files changed, 23 insertions(+), 20 deletions(-) rename keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/{ProviderPriority.kt => ProviderConfig.kt} (59%) diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index cc9f0ea3..bf19fde4 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -3,8 +3,8 @@ # --- YDB / JPA (this extension) --- # JDBC URL for YDB (required). Override with env for different hosts, e.g.: -spi-connections-jpa-default-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local -spi-connections-jpa-default-show-sql=true +spi-connections-jpa-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local +spi-connections-jpa-ydb-show-sql=true # YDB Liquibase provider: index creation threshold (optional, default 300000) # spi-connections-liquibase-ydb-liquibase-index-creation-threshold=300000 diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt index 2b90b4ee..68f72dcb 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt @@ -12,7 +12,8 @@ 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.ProviderPriority.PROVIDER_PRIORITY +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() { @@ -57,6 +58,8 @@ class YdbClientProviderFactory : JpaClientProviderFactory() { 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", diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt index 71edb3a0..ed2b416e 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt @@ -4,16 +4,17 @@ 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.ProviderPriority.PROVIDER_PRIORITY +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() { +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 { - return PROVIDER_PRIORITY - } + override fun order(): Int = PROVIDER_PRIORITY + + override fun getId(): String = PROVIDER_ID } \ No newline at end of file diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt similarity index 59% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt rename to keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt index f8f17425..725b63f6 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderPriority.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt @@ -1,5 +1,6 @@ package tech.ydb.keycloak.config -object ProviderPriority { +object ProviderConfig { const val PROVIDER_PRIORITY = Int.MAX_VALUE + const val PROVIDER_ID = "ydb" } diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt index 8278153d..f6b4f826 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt @@ -25,7 +25,8 @@ import org.keycloak.models.utils.KeycloakModelUtils import org.keycloak.provider.ServerInfoAwareProviderFactory import tech.ydb.hibernate.dialect.YdbDialect import tech.ydb.jdbc.YdbDriver -import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +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 @@ -78,7 +79,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) } } - private fun resolveJdbcUrl(): String = requireNotNull(config["ydbJdbcUrl"]) { + private fun resolveJdbcUrl(): String = requireNotNull(config["jdbcUrl"]) { "YDB JDBC URL is required" } @@ -143,7 +144,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf } } - override fun getId(): String = "default" + override fun getId(): String = PROVIDER_ID override fun order(): Int = PROVIDER_PRIORITY diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt index 77110bc8..3a9ae424 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt @@ -9,7 +9,8 @@ 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.ProviderPriority.PROVIDER_PRIORITY +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 @@ -68,7 +69,6 @@ class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider() { } companion object { - const val PROVIDER_ID: String = "ydb-liquibase" const val YDB_MASTER_CHANGELOG: String = "ydb/db.changelog-master.xml" private const val DEFAULT_INDEX_CREATION_THRESHOLD = 300000L diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt index 2020f9ab..5ab4f1c5 100644 --- a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt +++ b/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt @@ -6,7 +6,8 @@ import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSessionFactory import org.keycloak.models.RealmProviderFactory import org.keycloak.connections.jpa.JpaConnectionProvider -import tech.ydb.keycloak.config.ProviderPriority.PROVIDER_PRIORITY +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID +import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY class YdbRealmProviderFactory() : RealmProviderFactory { @@ -34,11 +35,7 @@ class YdbRealmProviderFactory() : RealmProviderFactory { // no operations } - override fun getId(): String = ID + override fun getId(): String = PROVIDER_ID override fun order(): Int = PROVIDER_PRIORITY - - private companion object { - private const val ID = "ydb-realm-provider-factory" - } } From c11fc3162621a95ef9749a0b0fbb4397976b22e8 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 00:54:48 +0300 Subject: [PATCH 070/120] chore: Move files from the root to core module --- keycloak-ydb-extension/{ => core}/pom.xml | 0 .../kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt | 0 .../tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt | 0 .../src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt | 0 .../ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt | 0 .../tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt | 0 .../src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt | 0 .../kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt | 0 .../org.keycloak.connections.jpa.JpaConnectionProviderFactory | 0 ....jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory | 0 .../META-INF/services/org.keycloak.models.ClientProviderFactory | 0 .../services/org.keycloak.models.ClientScopeProviderFactory | 0 .../META-INF/services/org.keycloak.models.RealmProviderFactory | 0 .../ydb/changesets/2025-12-17-21-07-create-realm-table.sql | 0 .../changesets/2025-12-17-21-29-create-realm-attributes-table.sql | 0 .../2025-12-17-22-00-create-realm-required-credentials-table.sql | 0 .../2025-12-20-00-20-create-realm_events_listeners-table.sql | 0 .../2025-12-20-00-21-create-realm-default-groups-table.sql | 0 .../2025-12-20-00-28-create-realm-enabled-event-types-table.sql | 0 .../2025-12-20-00-29-create-realm-localizations-table.sql | 0 .../2025-12-20-00-30-create-realm-smtp-config-table.sql | 0 .../2025-12-20-00-31-create-realm-supported-locales-table.sql | 0 .../2025-12-20-00-34-create-default-client-scope-table.sql | 0 .../2025-12-20-00-36-create-authentication-flow-table.sql | 0 .../2025-12-20-00-38-create-authentication-execution-table.sql | 0 .../2025-12-20-00-42-create-authenticator-config-table.sql | 0 .../2025-12-20-00-44-create-required-action-provider-table.sql | 0 .../2025-12-20-00-45-create-required-action-config-table.sql | 0 .../2025-12-20-00-46-create-authenticator-config-entry-table.sql | 0 .../ydb/changesets/2025-12-20-00-47-create-component-table.sql | 0 .../changesets/2025-12-20-00-48-create-component-config-table.sql | 0 .../ydb/changesets/2025-12-20-01-00-create-client-table.sql | 0 .../ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql | 0 .../ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql | 0 .../ydb/changesets/2026-02-01-16-04-composite-role-table.sql | 0 .../ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql | 0 .../changesets/2026-02-06-06-00-create-migration-model-table.sql | 0 .../2026-02-06-21-24-create-client-attributes-table.sql | 0 .../changesets/2026-02-06-21-34-create-redirect-uris-table.sql | 0 .../changesets/2026-02-06-21-35-create-protocol-mappers-table.sql | 0 .../2026-02-06-21-38-create-protocol-mapper-config-table.sql | 0 .../changesets/2026-02-07-02-06-create-scope-mapping-table.sql | 0 .../ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql | 0 .../ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql | 0 .../2026-02-07-15-50-create-client-scope-attributes-table.sql | 0 .../2026-02-07-15-51-create-client-scope-client-table.sql | 0 .../2026-02-07-15-52-create-client-scope-role-mapping-table.sql | 0 .../ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql | 0 .../2026-02-07-16-02-create-user-role-mapping-table.sql | 0 .../2026-02-07-16-05-create-identity-provider-table.sql | 0 .../2026-02-07-16-06-create-identity-provider-config-table.sql | 0 .../2026-02-07-16-07-create-identity-provider-mapper-table.sql | 0 .../ydb/changesets/2026-02-07-16-09-create-credential-table.sql | 0 .../changesets/2026-02-07-16-11-create-user-attribute-table.sql | 0 .../changesets/2026-02-07-16-15-create-revoked-token-table.sql | 0 .../2026-02-07-16-19-create-client-auth-flow-bindings-table.sql | 0 .../2026-02-07-16-21-create-client-node-registrations-table.sql | 0 .../2026-02-07-16-24-create-user-required-action-table.sql | 0 .../2026-02-07-16-26-create-offline-client-session-table.sql | 0 .../2026-02-07-16-27-create-offline-user-session-table.sql | 0 .../2026-02-07-16-37-create-user-group-membership-table.sql | 0 .../changesets/2026-02-07-16-40-create-keycloak-group-table.sql | 0 .../ydb/changesets/2026-02-07-16-53-create-org-table.sql | 0 .../ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql | 0 .../ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql | 0 .../2026-02-07-17-05-create-user-consent-client-scope-table.sql | 0 .../2026-02-07-17-08-create-federated-identity-table.sql | 0 .../changesets/2026-02-07-17-09-create-fed-user-consent-table.sql | 0 .../2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql | 0 .../2026-02-07-17-11-create-fed-user-credential-table.sql | 0 .../2026-02-07-17-12-create-fed-user-group-membership-table.sql | 0 .../2026-02-07-17-12-create-fed-user-required-action-table.sql | 0 .../2026-02-07-17-13-create-fed-user-role-mapping-table.sql | 0 .../changesets/2026-02-07-17-14-create-federated-user-table.sql | 0 .../ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql | 0 .../2026-02-07-17-16-create-fed-user-attribute-table.sql | 0 .../changesets/2026-02-07-17-17-create-group-attribute-table.sql | 0 .../2026-02-07-17-18-create-group-role-mapping-table.sql | 0 .../changesets/2026-02-08-19-53-create-resource-server-table.sql | 0 .../2026-02-08-19-54-create-resource-server-policy-table.sql | 0 .../2026-02-08-19-56-create-resource-server-resource-table.sql | 0 .../2026-02-08-19-57-create-resource-attribute-table.sql | 0 .../changesets/2026-02-08-19-58-create-resource-policy-table.sql | 0 .../2026-02-08-19-59-create-resource-server-scope-table.sql | 0 .../changesets/2026-02-08-20-00-create-resource-scope-table.sql | 0 .../2026-02-08-20-01-create-resource-server-perm-ticket-table.sql | 0 .../changesets/2026-02-08-20-02-create-resource-uris-table.sql | 0 .../changesets/2026-02-15-13-45-create-policy-config-table.sql | 0 .../2026-02-15-15-18-create-idp-mapper-config-table.sql | 0 .../2026-02-15-15-21-create-client-initial-access-table.sql | 0 .../2026-02-15-15-23-create-user-federation-provider-table.sql | 0 .../2026-02-15-15-24-create-user-federation-config-table.sql | 0 .../2026-02-15-15-25-create-user-federation-mapper-table.sql | 0 ...026-02-15-15-29-create-user-federation-mapper-config-table.sql | 0 .../{ => core}/src/main/resources/ydb/db.changelog-master.xml | 0 95 files changed, 0 insertions(+), 0 deletions(-) rename keycloak-ydb-extension/{ => core}/pom.xml (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql (100%) rename keycloak-ydb-extension/{ => core}/src/main/resources/ydb/db.changelog-master.xml (100%) diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/core/pom.xml similarity index 100% rename from keycloak-ydb-extension/pom.xml rename to keycloak-ydb-extension/core/pom.xml diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt diff --git a/keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt similarity index 100% rename from keycloak-ydb-extension/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt rename to keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory rename to keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql diff --git a/keycloak-ydb-extension/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 similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql rename to keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql diff --git a/keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml similarity index 100% rename from keycloak-ydb-extension/src/main/resources/ydb/db.changelog-master.xml rename to keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml From 139643f66c3970de3dbe2cf79e8e616e662eee46 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 26 Feb 2026 19:22:32 +0300 Subject: [PATCH 071/120] test: Implement RealmModelTest --- keycloak-ydb-extension/core/pom.xml | 40 +- keycloak-ydb-extension/pom.xml | 107 ++++ .../run-keycloak-with-ydb.sh | 4 +- keycloak-ydb-extension/test/pom.xml | 164 ++++++ .../tech/ydb/keycloak/testsuite/Config.java | 162 ++++++ .../testsuite/KeycloakModelParameters.java | 58 ++ .../keycloak/testsuite/KeycloakModelTest.java | 494 ++++++++++++++++++ .../keycloak/testsuite/RealmModelTest.java | 166 ++++++ .../keycloak/testsuite/RequireProvider.java | 16 + .../keycloak/testsuite/RequireProviders.java | 12 + .../keycloak/testsuite/parameters/Ydb.java | 157 ++++++ .../test/src/test/resources/log4j.properties | 75 +++ 12 files changed, 1424 insertions(+), 31 deletions(-) create mode 100644 keycloak-ydb-extension/pom.xml create mode 100644 keycloak-ydb-extension/test/pom.xml create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelParameters.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RealmModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProvider.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/RequireProviders.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java create mode 100644 keycloak-ydb-extension/test/src/test/resources/log4j.properties diff --git a/keycloak-ydb-extension/core/pom.xml b/keycloak-ydb-extension/core/pom.xml index c5b88e2f..16092c55 100644 --- a/keycloak-ydb-extension/core/pom.xml +++ b/keycloak-ydb-extension/core/pom.xml @@ -4,28 +4,18 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - tech.ydb + + tech.ydb + keycloak-ydb-extension-parent + 1.0-SNAPSHOT + ../pom.xml + + + jar + keycloak-ydb-extension (core) keycloak-ydb-extension 1.0-SNAPSHOT - - 21 - 21 - 21 - UTF-8 - - 2.2.21 - official - 21 - - 26.4.7 - - 2.3.20 - - 7.0.2 - 0.9.1 - - src/main/kotlin src/test/kotlin @@ -95,15 +85,7 @@ - maven-surefire-plugin - 3.5.4 - - - maven-failsafe-plugin - 3.5.4 - - - org.apache.maven.plugins + maven-shade-plugin 3.5.0 @@ -229,4 +211,4 @@ test - \ 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..a64450f9 --- /dev/null +++ b/keycloak-ydb-extension/pom.xml @@ -0,0 +1,107 @@ + + + 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 + + + + 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.1 + + + + + + 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/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index 1dc0e82d..e7cd4ef9 100644 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -1,10 +1,10 @@ rm -f docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar -mvn clean package +mvn -f core/pom.xml clean package mkdir -p docker/providers -JAR_FILE="target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" +JAR_FILE="core/target/keycloak-ydb-extension-1.0-SNAPSHOT.jar" if [ ! -f "$JAR_FILE" ]; then echo "Error: File $JAR_FILE not found!" diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml new file mode 100644 index 00000000..f0386f8a --- /dev/null +++ b/keycloak-ydb-extension/test/pom.xml @@ -0,0 +1,164 @@ + + 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 + + + org.junit.jupiter + junit-jupiter + test + + + tech.ydb.test + ydb-junit5-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 + + + tech.ydb.keycloak.testsuite.parameters.Ydb + 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/testsuite/Config.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java new file mode 100644 index 00000000..ac892b7c --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java @@ -0,0 +1,162 @@ +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) { + String fullKey = prefix + key; + String v = replaceProperties(getConfig().get(fullKey)); + if (v == null || v.isEmpty()) { + v = System.getProperty("keycloak." + fullKey); + } + return v != null && !v.isEmpty() ? v : null; + } + + @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/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..033a1784 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java @@ -0,0 +1,494 @@ +package tech.ydb.keycloak.testsuite; + +import com.google.common.collect.ImmutableSet; +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.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.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.BiConsumer; +import java.util.function.BiFunction; +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; + +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 = ImmutableSet.>builder() + .add(AuthorizationSpi.class) + .add(PolicySpi.class) + .add(ClientScopeSpi.class) + .add(ClientSpi.class) + .add(ComponentFactorySpi.class) + .add(EventStoreSpi.class) + .add(ExecutorsSpi.class) + .add(GroupSpi.class) + .add(RealmSpi.class) + .add(RoleSpi.class) + .add(DeploymentStateSpi.class) + .add(StoreFactorySpi.class) + .add(TimerSpi.class) + .add(UserLoginFailureSpi.class) + .add(UserSessionSpi.class) + .add(UserSpi.class) + .add(DatastoreSpi.class) + .add(TracingSpi.class) + .build(); + + private static final Set> ALLOWED_FACTORIES = + ImmutableSet.>builder() + .add(ComponentFactoryProviderFactory.class) + .add(DefaultAuthorizationProviderFactory.class) + .add(PolicyProviderFactory.class) + .add(DefaultExecutorsProviderFactory.class) + .add(DeploymentStateProviderFactory.class) + .add(DatastoreProviderFactory.class) + .add(TracingProviderFactory.class) + .build(); + + 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 R inComittedTransaction(T parameter, BiFunction what) { + return inComittedTransaction(parameter, what, 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); + } + + protected R inComittedTransaction( + T parameter, BiFunction what, BiConsumer onCommit) { + 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() { + } + }); + 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 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.realms().removeRealm(realm.getId()); + } + return s.realms().createRealm(name); + } +} 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/parameters/Ydb.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java new file mode 100644 index 00000000..38a670eb --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Ydb.java @@ -0,0 +1,157 @@ +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.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(LiquibaseConnectionProviderFactory.class) + .add(LiquibaseDBLockProviderFactory.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("group").defaultProvider("jpa") + .spi("idp").defaultProvider("jpa") + .spi("role").defaultProvider("jpa") + .spi("user").defaultProvider("jpa") + .spi("deploymentState").defaultProvider("jpa") + .spi("dblock").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/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 + From 7adfaa482473e69c988f855d0ee8334d667e253e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 22:39:43 +0300 Subject: [PATCH 072/120] test: Add TimeOffsetTest --- .../keycloak/testsuite/KeycloakModelTest.java | 10 +++ .../keycloak/testsuite/TimeOffsetTest.java | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/TimeOffsetTest.java 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 index 033a1784..c934a4d1 100644 --- 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 @@ -491,4 +491,14 @@ protected static RealmModel createRealm(KeycloakSession s, String name) { } 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); + }); + } } 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; + }); + } +} From 73c9db7f758a469bf179d83bf9274acd12b3dcb8 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 22:39:53 +0300 Subject: [PATCH 073/120] test: Add ClientModelTest --- .../testsuite/client/ClientModelTest.java | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/client/ClientModelTest.java 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; + }); + } +} From 3dff7592e656640fc26709b268bb0cc47721d35f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 23:18:41 +0300 Subject: [PATCH 074/120] test: Add GroupModelTest --- .../testsuite/group/GroupModelTest.java | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java 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..d56ff81d --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/group/GroupModelTest.java @@ -0,0 +1,212 @@ +package tech.ydb.keycloak.testsuite.group; + +import org.junit.Ignore; +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 + @Ignore("Implement YdbRealmProvider.removeGroup or use ignore FOR UPDATE in hibernate-dialect") + 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; + }); + } +} From 44305c7d49326ad296e8bfb778e104216e197a1b Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 23:24:27 +0300 Subject: [PATCH 075/120] test: Add ClientScopeModelTest --- keycloak-ydb-extension/test/pom.xml | 2 +- .../keycloak/testsuite/KeycloakModelTest.java | 66 +++++----- .../clientscope/ClientScopeModelTest.java | 107 ++++++++++++++++ .../testsuite/parameters/Infinispan.java | 113 +++++++++++++++++ .../test/src/test/resources/test-ispn.xml | 115 ++++++++++++++++++ 5 files changed, 371 insertions(+), 32 deletions(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/Infinispan.java create mode 100644 keycloak-ydb-extension/test/src/test/resources/test-ispn.xml diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index f0386f8a..6175b045 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -152,7 +152,7 @@ -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED - tech.ydb.keycloak.testsuite.parameters.Ydb + 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/testsuite/KeycloakModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/KeycloakModelTest.java index c934a4d1..f05bcd5a 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -34,6 +35,8 @@ 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; @@ -161,37 +164,38 @@ protected void finished(Description description) { } }; - private static final Set> ALLOWED_SPIS = ImmutableSet.>builder() - .add(AuthorizationSpi.class) - .add(PolicySpi.class) - .add(ClientScopeSpi.class) - .add(ClientSpi.class) - .add(ComponentFactorySpi.class) - .add(EventStoreSpi.class) - .add(ExecutorsSpi.class) - .add(GroupSpi.class) - .add(RealmSpi.class) - .add(RoleSpi.class) - .add(DeploymentStateSpi.class) - .add(StoreFactorySpi.class) - .add(TimerSpi.class) - .add(UserLoginFailureSpi.class) - .add(UserSessionSpi.class) - .add(UserSpi.class) - .add(DatastoreSpi.class) - .add(TracingSpi.class) - .build(); - - private static final Set> ALLOWED_FACTORIES = - ImmutableSet.>builder() - .add(ComponentFactoryProviderFactory.class) - .add(DefaultAuthorizationProviderFactory.class) - .add(PolicyProviderFactory.class) - .add(DefaultExecutorsProviderFactory.class) - .add(DeploymentStateProviderFactory.class) - .add(DatastoreProviderFactory.class) - .add(TracingProviderFactory.class) - .build(); + 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); 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/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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From edd019058c720f49e5c258022a58cca1477745ac Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 5 Mar 2026 23:34:26 +0300 Subject: [PATCH 076/120] test: Add RoleModelTest --- .../testsuite/role/RoleModelTest.java | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java 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..32a2fdbf --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/role/RoleModelTest.java @@ -0,0 +1,399 @@ +package tech.ydb.keycloak.testsuite.role; + +import org.hamcrest.Matcher; +import org.junit.Assume; +import org.junit.Ignore; +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 + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + public void testRolesWithIdsSearchQueries() { + testRolesWithIdsSearchQueries(this::getResult); + } + + @Test + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + 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 + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + public void testRolesWithIdsPaginationSearchQueries() { + testRolesWithIdsPaginationSearchQueries(this::getResult); + } + + @Test + @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") + 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 getRolePathTraversal() { + // Only perform this test if realm role ID = role.name and client role ID = client.id + ":" + role.name + Assume.assumeThat(mainRoleId, is(MAIN_ROLE_NAME)); + Assume.assumeTrue(rolesSubset.stream().anyMatch((CLIENT_NAME + ":" + ROLE_PREFIX + "10")::equals)); + + withRealm(realmId, (session, realm) -> { + RoleModel role = session.roles().getRoleById(realm, (CLIENT_NAME + ":" + ROLE_PREFIX + "10") + "/../../" + MAIN_ROLE_NAME); + assertThat(role, nullValue()); + 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); + } +} From 9c4bb37316179d5e3aad25efd586f3ba0f83f849 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 6 Mar 2026 00:13:05 +0300 Subject: [PATCH 077/120] test: Add ClientScopeStorageTest --- keycloak-ydb-extension/test/pom.xml | 2 +- .../clientscope/ClientScopeStorageTest.java | 59 ++++++ .../HardcodedClientScopeStorageProvider.java | 179 ++++++++++++++++++ ...odedClientScopeStorageProviderFactory.java | 42 ++++ .../testsuite/parameters/YdbFederation.java | 78 ++++++++ ...entscope.ClientScopeStorageProviderFactory | 1 + 6 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java create mode 100644 keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index 6175b045..f7803198 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -152,7 +152,7 @@ -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED - Ydb,Infinispan + Ydb,YdbFederation,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/testsuite/clientscope/ClientScopeStorageTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java new file mode 100644 index 00000000..76ef538a --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java @@ -0,0 +1,59 @@ +package tech.ydb.keycloak.testsuite.clientscope; + +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.*; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.clientscope.ClientScopeStorageProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; +import tech.ydb.keycloak.testsuite.KeycloakModelTest; +import tech.ydb.keycloak.testsuite.RequireProvider; + +@RequireProvider(RealmProvider.class) +@RequireProvider(ClientScopeStorageProvider.class) +public class ClientScopeStorageTest extends KeycloakModelTest { + + private String realmId; + private String clientScopeFederationId; + + @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 testGetClientScopeById() { + getParameters(ClientScopeStorageProviderModel.class).forEach(fs -> inComittedTransaction(fs, (session, federatedStorage) -> { + Assume.assumeThat("Cannot handle more than 1 client scope federation provider", clientScopeFederationId, Matchers.nullValue()); + RealmModel realm = session.realms().getRealm(realmId); + federatedStorage.setParentId(realmId); + federatedStorage.setEnabled(true); + federatedStorage.getConfig().putSingle(HardcodedClientScopeStorageProviderFactory.SCOPE_NAME, HardcodedClientScopeStorageProviderFactory.SCOPE_NAME); + ComponentModel res = realm.addComponentModel(federatedStorage); + clientScopeFederationId = res.getId(); + log.infof("Added %s client scope federation provider: %s", federatedStorage.getName(), clientScopeFederationId); + return null; + })); + + inComittedTransaction(1, (session, i) -> { + final RealmModel realm = session.realms().getRealm(realmId); + StorageId storageId = new StorageId(clientScopeFederationId, "scope_name"); + ClientScopeModel hardcoded = session.clientScopes().getClientScopeById(realm, storageId.getId()); + Assert.assertNotNull(hardcoded); + return null; + }); + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java new file mode 100644 index 00000000..93041bb8 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java @@ -0,0 +1,179 @@ +/* + * Copyright 2021 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. + */ +package tech.ydb.keycloak.testsuite.clientscope; + +import org.keycloak.models.*; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.clientscope.ClientScopeLookupProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Stream; + + +public class HardcodedClientScopeStorageProvider implements ClientScopeStorageProvider, ClientScopeLookupProvider { + + private final ClientScopeStorageProviderModel component; + private final String clientScopeName; + + public HardcodedClientScopeStorageProvider(KeycloakSession session, ClientScopeStorageProviderModel component) { + this.component = component; + this.clientScopeName = component.getConfig().getFirst(HardcodedClientScopeStorageProviderFactory.SCOPE_NAME); + } + + @Override + public ClientScopeModel getClientScopeById(RealmModel realm, String id) { + StorageId storageId = new StorageId(id); + final String scopeName = storageId.getExternalId(); + if (this.clientScopeName.equals(scopeName)) return new HardcodedClientScopeAdapter(realm); + return null; + } + + @Override + public void close() { + } + + public class HardcodedClientScopeAdapter implements ClientScopeModel { + + private final RealmModel realm; + private StorageId storageId; + + public HardcodedClientScopeAdapter(RealmModel realm) { + this.realm = realm; + } + + @Override + public String getId() { + if (storageId == null) { + storageId = new StorageId(component.getId(), getName()); + } + return storageId.getId(); + } + + @Override + public String getName() { + return clientScopeName; + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public void setName(String name) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public String getDescription() { + return "Federated client scope"; + } + + @Override + public void setDescription(String description) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public String getProtocol() { + return "openid-connect"; + } + + @Override + public void setProtocol(String protocol) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void setAttribute(String name, String value) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void removeAttribute(String name) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public String getAttribute(String name) { + return null; + } + + @Override + public Map getAttributes() { + return Collections.EMPTY_MAP; + } + + @Override + public Stream getProtocolMappersStream() { + return Stream.empty(); + } + + @Override + public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void removeProtocolMapper(ProtocolMapperModel mapping) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void updateProtocolMapper(ProtocolMapperModel mapping) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public ProtocolMapperModel getProtocolMapperById(String id) { + return null; + } + + @Override + public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) { + return null; + } + + @Override + public Stream getScopeMappingsStream() { + return Stream.empty(); + } + + @Override + public Stream getRealmScopeMappingsStream() { + return Stream.empty(); + } + + @Override + public void addScopeMapping(RoleModel role) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public void deleteScopeMapping(RoleModel role) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public boolean hasScope(RoleModel role) { + return false; + } + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java new file mode 100644 index 00000000..957c5cc3 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java @@ -0,0 +1,42 @@ +package tech.ydb.keycloak.testsuite.clientscope; + +import java.util.List; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; + +public class HardcodedClientScopeStorageProviderFactory implements ClientScopeStorageProviderFactory { + + public static final String PROVIDER_ID = "hardcoded-clientscope"; + public static final String SCOPE_NAME = "scope_name"; + protected static final List CONFIG_PROPERTIES; + + @Override + public HardcodedClientScopeStorageProvider create(KeycloakSession session, ComponentModel model) { + return new HardcodedClientScopeStorageProvider(session, new ClientScopeStorageProviderModel(model)); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + static { + CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property().name(SCOPE_NAME) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Hardcoded Scope Name") + .helpText("Only this scope name is available for lookup") + .defaultValue("hardcoded-clientscope") + .add() + .build(); + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } +} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java new file mode 100644 index 00000000..2dc258f8 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java @@ -0,0 +1,78 @@ +/* + * Copyright 2020 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. + */ +package tech.ydb.keycloak.testsuite.parameters; + +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; +import org.keycloak.storage.UserStorageProviderSpi; +import org.keycloak.storage.federated.UserFederatedStorageProviderSpi; +import org.keycloak.storage.jpa.JpaUserFederatedStorageProviderFactory; +import com.google.common.collect.ImmutableSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.keycloak.storage.clientscope.ClientScopeStorageProvider; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; +import org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi; +import tech.ydb.keycloak.testsuite.Config; +import tech.ydb.keycloak.testsuite.KeycloakModelParameters; +import tech.ydb.keycloak.testsuite.clientscope.HardcodedClientScopeStorageProviderFactory; + +/** + * + * @author hmlnarik + */ +public class YdbFederation extends KeycloakModelParameters { + + private final AtomicInteger counter = new AtomicInteger(); + + static final Set> ALLOWED_SPIS = ImmutableSet.>builder() + .addAll(Ydb.ALLOWED_SPIS) + .add(UserStorageProviderSpi.class) + .add(UserFederatedStorageProviderSpi.class) + .add(ClientScopeStorageProviderSpi.class) + + .build(); + + static final Set> ALLOWED_FACTORIES = ImmutableSet.>builder() + .addAll(Ydb.ALLOWED_FACTORIES) + .add(JpaUserFederatedStorageProviderFactory.class) + .add(ClientScopeStorageProviderFactory.class) + .build(); + + public YdbFederation() { + super(ALLOWED_SPIS, ALLOWED_FACTORIES); + } + + @Override + public Stream getParameters(Class clazz) { + if (ClientScopeStorageProviderModel.class.isAssignableFrom(clazz)) { + ClientScopeStorageProviderModel federatedStorage = new ClientScopeStorageProviderModel(); + federatedStorage.setName(HardcodedClientScopeStorageProviderFactory.PROVIDER_ID + ":" + counter.getAndIncrement()); + federatedStorage.setProviderId(HardcodedClientScopeStorageProviderFactory.PROVIDER_ID); + federatedStorage.setProviderType(ClientScopeStorageProvider.class.getName()); + return Stream.of((T) federatedStorage); + } else { + return super.getParameters(clazz); + } + } + + @Override + public void updateConfig(Config cf) { + } +} diff --git a/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory b/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory new file mode 100644 index 00000000..5134756f --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory @@ -0,0 +1 @@ +tech.ydb.keycloak.testsuite.clientscope.HardcodedClientScopeStorageProviderFactory \ No newline at end of file From eecc4d8d2f820fa75aeebe1fa32acf0425b40a0a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 6 Mar 2026 00:28:14 +0300 Subject: [PATCH 078/120] test: Add AdminEventQueryTest --- ...-00-21-create-admin-event-entity-table.sql | 23 +++ .../resources/ydb/db.changelog-master.xml | 3 + .../testsuite/events/AdminEventQueryTest.java | 190 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/AdminEventQueryTest.java 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..e14ca3f1 --- /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,23 @@ +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), + INDEX idx_admin_event_realm GLOBAL ON (REALM_ID), + INDEX idx_admin_event_user GLOBAL ON (AUTH_USER_ID), + INDEX idx_admin_event_client GLOBAL ON (AUTH_CLIENT_ID), + INDEX idx_admin_event_operation GLOBAL ON (OPERATION_TYPE), + PRIMARY KEY (ID) +); \ No newline at end of file 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 index c3e4f154..35b45f02 100644 --- 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 @@ -247,4 +247,7 @@ + + + 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"; + } + }; + } + } + +} From c7d036c28beecf3ccc364b8cf877ddc4eeca458e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 6 Mar 2026 00:29:58 +0300 Subject: [PATCH 079/120] test: Add EventQueryTest --- .../keycloak/testsuite/KeycloakModelTest.java | 10 + .../testsuite/events/EventQueryTest.java | 186 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/events/EventQueryTest.java 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 index f05bcd5a..8c2dc8c9 100644 --- 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 @@ -433,6 +433,16 @@ 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); } 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; + } + } + +} From d8755afc62d5c966ef9321f2baa758cb458d5a86 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 8 Mar 2026 00:17:08 +0300 Subject: [PATCH 080/120] feat: Use junit 4 as it is in keycloak --- keycloak-ydb-extension/test/pom.xml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index f7803198..7cc91fa9 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -100,14 +100,9 @@ 1.20.6 test - - org.junit.jupiter - junit-jupiter - test - tech.ydb.test - ydb-junit5-support + ydb-junit4-support 2.3.27 test From fafd6787feff47b464bb6a7b93a0c5f7588ef1ce Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Mar 2026 02:45:54 +0300 Subject: [PATCH 081/120] test: Add StorageTransactionTest --- .../transaction/StorageTransactionTest.java | 100 +++++++++++++++ .../testsuite/util/TransactionController.java | 115 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/transaction/StorageTransactionTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/util/TransactionController.java 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/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(); + } + } +} From 64fad3d6356c8afb8436a63ccf667da21eab16c5 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 15 Mar 2026 16:44:53 +0300 Subject: [PATCH 082/120] test: Add SingleUseObjectModelTest.java --- .../tech/ydb/keycloak/testsuite/Config.java | 9 +- .../keycloak/testsuite/KeycloakModelTest.java | 37 ++- .../SingleUseObjectModelTest.java | 293 ++++++++++++++++++ 3 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/singleUseObject/SingleUseObjectModelTest.java 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 index ac892b7c..6d949e4c 100644 --- 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 @@ -101,12 +101,17 @@ public MapConfigScope(String 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); + v = System.getProperty("keycloak." + fullKey, defaultValue); } - return v != null && !v.isEmpty() ? v : null; + return v != null && !v.isEmpty() ? v : defaultValue; } @Override 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 index 8c2dc8c9..4246f8e2 100644 --- 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 @@ -1,6 +1,5 @@ package tech.ydb.keycloak.testsuite; -import com.google.common.collect.ImmutableSet; import org.hamcrest.Matchers; import org.jboss.logging.Logger; import org.junit.*; @@ -49,14 +48,13 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.function.Function; +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(); @@ -515,4 +513,33 @@ protected void setTimeOffset(int seconds) { 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/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); + }); + } +} From d519d0d9b91b72d79595c101f96ac29f4420c41a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 20 Mar 2026 02:07:17 +0300 Subject: [PATCH 083/120] fix: Use new bootstrap-admin in conf to fix admin login --- keycloak-ydb-extension/docker/conf/keycloak.conf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index bf19fde4..603f5e3a 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -13,6 +13,6 @@ spi-connections-jpa-ydb-show-sql=true spi-connections-jpa-quarkus-enabled=false spi-connections-liquibase-quarkus-enabled=false -# --- Dev mode admin (optional; for start-dev) --- -keycloak-admin=admin -keycloak-admin-password=admin +# --- Bootstrap admin (Keycloak 26.x) --- +bootstrap-admin-username=admin +bootstrap-admin-password=admin From f75cda6801d97875058a963a75c9786412dc4655 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 20 Mar 2026 20:33:25 +0300 Subject: [PATCH 084/120] feat: Handle ydb specific queries by queries-ydb.properties file --- .../YdbConnectionProviderFactoryImpl.kt | 12 +++++++ .../resources/META-INF/queries-ydb.properties | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties 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 index f6b4f826..71022c9f 100644 --- 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 @@ -197,11 +197,22 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf 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() @@ -309,5 +320,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf } const val PERSISTENCE_UNIT_NAME = "keycloak-default" + const val DB_KIND = "ydb" } } 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..37aa33bf --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties @@ -0,0 +1,31 @@ +# 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 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 From d4c9478cde108fd87b64b53c065ea7a0c2ffd80b Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sat, 11 Apr 2026 17:28:33 +0300 Subject: [PATCH 085/120] test: Add MigrationModelTest --- ...-06-06-00-create-migration-model-table.sql | 2 + .../tech/ydb/keycloak/MigrationModelTest.java | 222 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java 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 index 1b8a6293..8d35c63e 100644 --- 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 @@ -4,5 +4,7 @@ CREATE TABLE IF NOT EXISTS MIGRATION_MODEL `VERSION` Utf8, `UPDATE_TIME` Int64 NOT NULL DEFAULT 0, + INDEX UK_MIGRATION_VERSION GLOBAL UNIQUE SYNC ON (`VERSION`), + INDEX UK_MIGRATION_UPDATE_TIME GLOBAL UNIQUE SYNC ON (`UPDATE_TIME`), PRIMARY KEY (`ID`) ); 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..ca9efe59 --- /dev/null +++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java @@ -0,0 +1,222 @@ +package tech.ydb.keycloak; + +import jakarta.persistence.EntityManager; +import org.jboss.logging.Logger; +import org.junit.Ignore; +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 + @Ignore("After https://github.com/ydb-platform/ydb-java-dialects/pull/212") + 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 + @Ignore("After https://github.com/ydb-platform/ydb-java-dialects/pull/212") + 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 + @Ignore("After https://github.com/ydb-platform/ydb-java-dialects/pull/212") + 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 From 63251b750e4bb7d5a11682ec089ec025072a168c Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sun, 22 Mar 2026 23:12:04 +0300 Subject: [PATCH 086/120] feat: Add YdbRetryableException handling to respond with 503 error code with specific message --- .../YdbConnectionProviderFactoryImpl.kt | 8 ++- .../connection/YdbEntityManagerProxy.kt | 56 +++++++++++++++++++ .../connection/YdbJpaKeycloakTransaction.kt | 36 ++++++++++++ .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 13 +++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt 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 index 71022c9f..0e802301 100644 --- 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 @@ -10,7 +10,6 @@ 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.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.connections.jpa.updater.JpaUpdaterProvider import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY @@ -55,10 +54,13 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf emf.createEntityManager(SYNCHRONIZED) } + val keycloakEm = EntityManagerProxy.create(session, em, true) + val ydbEm = YdbEntityManagerProxy.create(keycloakEm) + if (!jtaEnabled) { - session.transactionManager.enlist(JpaKeycloakTransaction(em)) + session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm)) } - return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) + return DefaultJpaConnectionProvider(ydbEm) } override fun init(scope: Config.Scope) { 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..16b22c38 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt @@ -0,0 +1,56 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.jboss.logging.Logger +import tech.ydb.keycloak.utils.isYdbRetryable +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +// TODO: add unit test +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 ydbRetryableResponse(cause) + } + throw cause + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw ydbRetryableResponse(e) + } + throw e + } + } + + private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( + cause.message, + cause, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + + 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 + } + } +} \ No newline at end of file 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..325e6bd4 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt @@ -0,0 +1,36 @@ +package tech.ydb.keycloak.connection + +import jakarta.persistence.EntityManager +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.jboss.logging.Logger +import org.keycloak.connections.jpa.JpaKeycloakTransaction +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 WebApplicationException( + "YDB transaction aborted due to contention", + e, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + } + 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/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt new file mode 100644 index 00000000..cde51afb --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt @@ -0,0 +1,13 @@ +package tech.ydb.keycloak.utils + +import tech.ydb.jdbc.exception.YdbRetryableException + +// TODO: add unit test +fun isYdbRetryable(t: Throwable): Boolean { + var cause: Throwable? = t + while (cause != null) { + if (cause is YdbRetryableException) return true + cause = cause.cause + } + return false +} From 6e28a11ed1e8649cf27f0657240c94b77547d2ab Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 00:04:40 +0300 Subject: [PATCH 087/120] feat: Add hikari connection pool because default hibernate pool it not intended for production use --- keycloak-ydb-extension/README.md | 17 +++++++++++++ .../YdbConnectionProviderFactoryImpl.kt | 24 +++++++++++++++++-- .../docker/conf/keycloak.conf | 12 ++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md index 5f8b69f5..fdb3c925 100644 --- a/keycloak-ydb-extension/README.md +++ b/keycloak-ydb-extension/README.md @@ -17,6 +17,23 @@ When using YDB you must avoid giving the YDB URL to Keycloak’s default datasou 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). 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 index 0e802301..2d894519 100644 --- 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 @@ -28,6 +28,8 @@ 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 com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource import java.sql.Connection import java.sql.DriverManager import java.util.* @@ -44,6 +46,8 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf @Volatile private lateinit var entityManagerFactory: EntityManagerFactory + private lateinit var dataSource: HikariDataSource + override fun create(session: KeycloakSession): JpaConnectionProvider { val emf = getOrCreateEntityManagerFactory(session) @@ -144,6 +148,9 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf if (::entityManagerFactory.isInitialized) { entityManagerFactory.close() } + if (::dataSource.isInitialized) { + dataSource.close() + } } override fun getId(): String = PROVIDER_ID @@ -218,8 +225,21 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf private fun buildPropertiesFromScope(): MutableMap { val properties = mutableMapOf() - properties[AvailableSettings.JAKARTA_JDBC_URL] = resolveJdbcUrl() - properties[AvailableSettings.JAKARTA_JDBC_DRIVER] = YdbDriver::class.java.name + val jdbcUrl = resolveJdbcUrl() + + val hikariConfig = HikariConfig().apply { + this.jdbcUrl = 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.infof("HikariCP pool created: maxSize=%d, minIdle=%d", hikariConfig.maximumPoolSize, hikariConfig.minimumIdle) + + properties[AvailableSettings.JAKARTA_NON_JTA_DATASOURCE] = dataSource getSchema()?.let { properties[JpaUtils.HIBERNATE_DEFAULT_SCHEMA] = it } diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index 603f5e3a..ff0b5d26 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -4,9 +4,17 @@ # --- 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=true +spi-connections-jpa-ydb-show-sql=false +# spi-connections-jpa-ydb-format-sql=true -# YDB Liquibase provider: index creation threshold (optional, default 300000) +# --- 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 From 73da4758befb861d52ef2fb8f373be2e28c8665e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:16:41 +0300 Subject: [PATCH 088/120] feat: Add retry proxy for keycloak --- .../docker/docker-compose.yml | 18 +- keycloak-ydb-extension/pom.xml | 4 + keycloak-ydb-extension/retry-proxy/Dockerfile | 10 + keycloak-ydb-extension/retry-proxy/README.md | 37 +++ keycloak-ydb-extension/retry-proxy/pom.xml | 170 +++++++++++++ .../tech/ydb/keycloak/proxy/Dependencies.kt | 14 ++ .../tech/ydb/keycloak/proxy/RetryProxy.kt | 19 ++ .../ydb/keycloak/proxy/client/ProxyClient.kt | 17 ++ .../ydb/keycloak/proxy/config/ProxyConfig.kt | 35 +++ .../proxy/controller/ProxyController.kt | 55 +++++ .../ydb/keycloak/proxy/plugins/Routing.kt | 15 ++ .../keycloak/proxy/service/ProxyService.kt | 118 +++++++++ .../ydb/keycloak/proxy/utils/HeaderUtils.kt | 19 ++ .../proxy/controller/ProxyControllerTest.kt | 127 ++++++++++ .../proxy/service/ProxyServiceTest.kt | 225 ++++++++++++++++++ .../keycloak/proxy/utils/HeaderUtilsTest.kt | 50 ++++ .../run-keycloak-with-ydb.sh | 34 ++- 17 files changed, 958 insertions(+), 9 deletions(-) create mode 100644 keycloak-ydb-extension/retry-proxy/Dockerfile create mode 100644 keycloak-ydb-extension/retry-proxy/README.md create mode 100644 keycloak-ydb-extension/retry-proxy/pom.xml create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt mode change 100644 => 100755 keycloak-ydb-extension/run-keycloak-with-ydb.sh diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 08147aba..932ce3a6 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -44,10 +44,24 @@ services: command: > -v start-dev --cache=local - ports: - - 9090:8080 networks: - keycloak depends_on: ydb: condition: service_started + + retry-proxy: + build: + context: ../retry-proxy + environment: + - TARGET_URL=http://keycloak:8080 + - MAX_RETRIES=10 + - BASE_DELAY_MS=50 + - MAX_DELAY_MS=2000 + - LISTEN_PORT=8080 + ports: + - 9090:8080 + networks: + - keycloak + depends_on: + - keycloak diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index a64450f9..e2dd96fa 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -15,6 +15,7 @@ core test + retry-proxy @@ -36,6 +37,9 @@ 7.0.2 0.9.1 + + 3.4.1 + 1.5.16 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..c047e822 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -0,0 +1,37 @@ +# 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) | + +### 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..d0fe3bc3 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/pom.xml @@ -0,0 +1,170 @@ + + + 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-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..a12643d4 --- /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(), + ) + } +} \ No newline at end of file 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..bed163f2 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt @@ -0,0 +1,55 @@ +package tech.ydb.keycloak.proxy.controller + +import io.ktor.http.* +import io.ktor.server.application.* +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 result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host) + + 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}" + + result.headers.forEach { name, values -> + if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) { + values.forEach { value -> + response.header(name, rewriteInternalUrl(name, value, originalHost)) + } + } + } + + respondBytes(result.body, result.contentType, result.status) + } + + private fun rewriteInternalUrl(name: String, value: String, originalHost: String): String { + if (isHeader(name, HttpHeaders.Location)) { + return value.replace(config.targetUrl, "http://$originalHost") + } + return value + } +} \ No newline at end of file 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..dd7b554c --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt @@ -0,0 +1,15 @@ +package tech.ydb.keycloak.proxy.plugins + +import io.ktor.server.application.* +import io.ktor.server.routing.* +import tech.ydb.keycloak.proxy.Dependencies + +fun Application.configureRouting(deps: Dependencies) { + routing { + route("{...}") { + handle { + deps.controller.handle(call) + } + } + } +} \ No newline at end of file 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..a2e38431 --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt @@ -0,0 +1,118 @@ +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 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.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, + ): 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) + } catch (e: Exception) { + return Error("Proxy error: ${e.message}") + } + + val responseBody = response.readRawBytes() + + val isRetryable = response.status.value == 503 && String(responseBody).contains("ydb_retryable") + + if (isRetryable) { + 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, + ): HttpResponse = client.request("${config.targetUrl}$path") { + this.method = method + + copyHeaders(headers) + header("X-Forwarded-Host", host) + header("X-Forwarded-Proto", "http") + header("X-Forwarded-Port", host.substringAfter(":", "80")) + + 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/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..b3b4f20a --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt @@ -0,0 +1,127 @@ +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()) } 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()) } 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()) } 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()) } returns + Success( + body = ByteArray(0), + headers = headersOf(HttpHeaders.Location, "http://backend:8080/realms/master"), + contentType = null, + status = HttpStatusCode.Found, + ) + + createClient { followRedirects = false }.get("/login").let { + assertEquals(HttpStatusCode.Found, it.status) + assertTrue(it.headers[HttpHeaders.Location]!!.contains("/realms/master")) + assertFalse(it.headers[HttpHeaders.Location]!!.contains("backend:8080")) + } + } + + @Test + fun clientDisconnectedReturnsNothing() = withProxy { + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + ClientDisconnected + + client.get("/disconnect").let { + assertEquals("", it.bodyAsText()) + } + } +} \ No newline at end of file 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..14149a9a --- /dev/null +++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt @@ -0,0 +1,225 @@ +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", + ) + + @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 setsForwardedHeaders() = runTest { + var capturedHeaders: Headers? = null + val service = proxyService(mockClient { request -> + capturedHeaders = request.headers + respond("ok", HttpStatusCode.OK) + }) + + doRequest(service) + assertEquals("localhost:9090", capturedHeaders!!["X-Forwarded-Host"]) + assertEquals("http", capturedHeaders!!["X-Forwarded-Proto"]) + assertEquals("9090", capturedHeaders!!["X-Forwarded-Port"]) + } + + @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", + ) + + 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) + } + } +} \ No newline at end of file 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..00c0eb20 --- /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)) + } +} \ No newline at end of file diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh old mode 100644 new mode 100755 index e7cd4ef9..b0052f85 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -1,17 +1,37 @@ -rm -f docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar +#!/usr/bin/env bash +set -euo pipefail -mvn -f core/pom.xml clean package +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" -mkdir -p docker/providers +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: File $JAR_FILE not found!" - echo "The project build may have failed." + 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 -docker-compose -f docker/docker-compose.yml up -d \ No newline at end of file +echo "" +echo "Stack is starting. Wait ~30-60s for Keycloak to initialize." +echo "" +echo " Keycloak (via retry-proxy): http://localhost:9090" +echo " Keycloak (direct): http://localhost:8080" +echo " YDB Monitoring: http://localhost:8765" +echo " Admin credentials: admin / admin" +echo "" +echo "Check logs: docker compose -f docker/docker-compose.yml logs -f keycloak" From fcf6bb6c9b6f957327cb6008fcf8b51138ac43a4 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:43:00 +0300 Subject: [PATCH 089/120] feat: Use Forwarded header instead of X-Forwarded https://medium.com/@lm.moreira/a-complete-microservice-edge-ecosystem-with-ngix-oauth2-proxy-keycloak-and-spring-boot-part-1-441104e5f96b --- .../ydb/keycloak/proxy/controller/ProxyController.kt | 4 +++- .../tech/ydb/keycloak/proxy/service/ProxyService.kt | 11 +++++++---- .../keycloak/proxy/controller/ProxyControllerTest.kt | 10 +++++----- .../ydb/keycloak/proxy/service/ProxyServiceTest.kt | 10 ++++++---- 4 files changed, 21 insertions(+), 14 deletions(-) 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 index bed163f2..e3ccaa3d 100644 --- 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 @@ -20,8 +20,10 @@ class ProxyController( val body = call.receive() val contentType = call.request.contentType() val host = call.request.headers["Host"] ?: call.request.host() + val remoteHost = call.request.local.remoteHost + val scheme = call.request.local.scheme - val result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host) + val result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host, remoteHost, scheme) when (result) { is Success -> call.handleSuccess(result) 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 index a2e38431..4de789d9 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -28,6 +29,8 @@ class ProxyService( contentType: ContentType, headers: Headers, host: String, + remoteHost: String, + scheme: String, ): ProxyResult { for (attempt in 0..config.maxRetries) { if (!coroutineContext.isActive) { @@ -36,7 +39,7 @@ class ProxyService( } val response = try { - forwardToTarget(method, path, body, contentType, headers, host) + forwardToTarget(method, path, body, contentType, headers, host, remoteHost, scheme) } catch (e: Exception) { return Error("Proxy error: ${e.message}") } @@ -76,13 +79,13 @@ class ProxyService( contentType: ContentType, headers: Headers, host: String, + remoteHost: String, + scheme: String, ): HttpResponse = client.request("${config.targetUrl}$path") { this.method = method copyHeaders(headers) - header("X-Forwarded-Host", host) - header("X-Forwarded-Proto", "http") - header("X-Forwarded-Port", host.substringAfter(":", "80")) + header(Forwarded, "for=$remoteHost;host=$host;proto=$scheme") setBody(OutgoingByteArrayContent(body, contentType)) } 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 index b3b4f20a..085e59d2 100644 --- 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 @@ -49,7 +49,7 @@ class ProxyControllerTest { @Test fun forwardsSuccessResponse() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Success( body = "hello".toByteArray(), headers = headersOf(), @@ -65,7 +65,7 @@ class ProxyControllerTest { @Test fun returnsErrorAsBadGateway() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Error("connection refused") client.get("/fail").let { @@ -76,7 +76,7 @@ class ProxyControllerTest { @Test fun filtersHopByHopHeaders() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Success( body = "ok".toByteArray(), headers = headersOf( @@ -100,7 +100,7 @@ class ProxyControllerTest { @Test fun rewritesLocationHeader() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + 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"), @@ -117,7 +117,7 @@ class ProxyControllerTest { @Test fun clientDisconnectedReturnsNothing() = withProxy { - coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any()) } returns + coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns ClientDisconnected client.get("/disconnect").let { 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 index 14149a9a..2e285d82 100644 --- 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 @@ -38,6 +38,8 @@ class ProxyServiceTest { contentType = ContentType.Application.Json, headers = headersOf(), host = "localhost:9090", + remoteHost = "127.0.0.1", + scheme = "http", ) @Test @@ -163,7 +165,7 @@ class ProxyServiceTest { } @Test - fun setsForwardedHeaders() = runTest { + fun setsForwardedHeader() = runTest { var capturedHeaders: Headers? = null val service = proxyService(mockClient { request -> capturedHeaders = request.headers @@ -171,9 +173,7 @@ class ProxyServiceTest { }) doRequest(service) - assertEquals("localhost:9090", capturedHeaders!!["X-Forwarded-Host"]) - assertEquals("http", capturedHeaders!!["X-Forwarded-Proto"]) - assertEquals("9090", capturedHeaders!!["X-Forwarded-Port"]) + assertEquals("for=127.0.0.1;host=localhost:9090;proto=http", capturedHeaders!![HttpHeaders.Forwarded]) } @Test @@ -198,6 +198,8 @@ class ProxyServiceTest { HttpHeaders.Authorization to listOf("Bearer token"), ), host = "localhost:9090", + remoteHost = "127.0.0.1", + scheme = "http", ) assertNull(capturedHeaders!![HttpHeaders.Connection]) From 5bc524a1351f8f188bbef13baf30522e0bc1d2d0 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:43:27 +0300 Subject: [PATCH 090/120] feat: Add load testing setup for ydb --- .../docker/conf/keycloak.conf | 5 + keycloak-ydb-extension/load-test/.gitignore | 2 + keycloak-ydb-extension/load-test/README.md | 108 +++++++ .../load-test/delete-all-users.py | 92 ++++++ keycloak-ydb-extension/load-test/prepare.sh | 46 +++ keycloak-ydb-extension/load-test/run.sh | 72 +++++ .../load-test/setup-test-realm.py | 280 ++++++++++++++++++ 7 files changed, 605 insertions(+) create mode 100644 keycloak-ydb-extension/load-test/.gitignore create mode 100644 keycloak-ydb-extension/load-test/README.md create mode 100644 keycloak-ydb-extension/load-test/delete-all-users.py create mode 100755 keycloak-ydb-extension/load-test/prepare.sh create mode 100755 keycloak-ydb-extension/load-test/run.sh create mode 100644 keycloak-ydb-extension/load-test/setup-test-realm.py diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index ff0b5d26..96388166 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -24,3 +24,8 @@ 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/load-test/.gitignore b/keycloak-ydb-extension/load-test/.gitignore new file mode 100644 index 00000000..219dec50 --- /dev/null +++ b/keycloak-ydb-extension/load-test/.gitignore @@ -0,0 +1,2 @@ +lib/ +results/ \ No newline at end of file diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md new file mode 100644 index 00000000..9a1909fc --- /dev/null +++ b/keycloak-ydb-extension/load-test/README.md @@ -0,0 +1,108 @@ +# Load Testing + +Load tests for Keycloak + YDB using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). + +## 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. Build and start infrastructure + +From the `keycloak-ydb-extension/` root: + +```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 keycloak +``` + +Services: +- Keycloak (via retry-proxy): http://localhost:9090 +- YDB Monitoring: http://localhost:8765 +- Admin credentials: `admin` / `admin` + +### 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. + +By default connects to `http://localhost:9090`. To use a different URL: + +```bash +python3 setup-test-realm.py http://localhost:8080 +``` + +### 4. Run load test + +```bash +./run.sh [measurement-sec] [server-url] +``` + +Examples: + +```bash +./run.sh CreateUsers 30 # 30 rps, 60s measurement +./run.sh CreateUsers 30 120 # 30 rps, 120s measurement +./run.sh CreateDeleteUsers 10 60 # 10 rps, 60s +``` + +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 | Description | +|----------|-------------| +| `CreateUsers` | Create user + List users | +| `CreateDeleteUsers` | Create user + List users + Delete user | +| `CreateClients` | Create client | +| `CreateDeleteClients` | Create client + Delete client | +| `ClientSecret` | Client credentials grant (authentication) | +| `AuthorizationCode` | Authorization code flow (authentication) | + +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 + run.sh # Runs Gatling scenario + 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) +``` \ No newline at end of file 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..2b75a789 --- /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/" \ No newline at end of file diff --git a/keycloak-ydb-extension/load-test/run.sh b/keycloak-ydb-extension/load-test/run.sh new file mode 100755 index 00000000..a1249453 --- /dev/null +++ b/keycloak-ydb-extension/load-test/run.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Runs a Gatling load test scenario against Keycloak. +# +# Usage: +# ./run.sh [measurement-sec] [server-url] +# +# Examples: +# ./run.sh CreateUsers 30 +# ./run.sh CreateUsers 30 60 +# ./run.sh CreateDeleteUsers 10 60 http://localhost:9090 +# +# Available scenarios: +# CreateUsers - Create + List users +# CreateDeleteUsers - Create + List + Delete users +# ClientSecret - Client credentials grant +# AuthorizationCode - Authorization code flow +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" +RESULTS_DIR="$SCRIPT_DIR/results" + +if [ ! -d "$LIB_DIR" ] || [ -z "$(ls -A "$LIB_DIR" 2>/dev/null)" ]; then + echo "ERROR: No JARs found in $LIB_DIR" + echo "Run ./prepare.sh first" + exit 1 +fi + +SCENARIO="${1:?Usage: ./run.sh [measurement-sec] [server-url]}" +USERS_PER_SEC="${2:?Usage: ./run.sh [measurement-sec] [server-url]}" +MEASUREMENT="${3:-60}" +SERVER_URL="${4:-http://localhost:9090}" +REALM="${5:-test-realm}" + +# Resolve full scenario class name +case "$SCENARIO" in + CreateUsers|CreateDeleteUsers|CreateClients|CreateDeleteClients|CreateRealms) + SCENARIO_CLASS="keycloak.scenario.admin.$SCENARIO" + ;; + ClientSecret|AuthorizationCode) + SCENARIO_CLASS="keycloak.scenario.authentication.$SCENARIO" + ;; + *) + SCENARIO_CLASS="$SCENARIO" + ;; +esac + +CLASSPATH=$(find "$LIB_DIR" -type f -name '*.jar' | tr '\n' ':') + +echo "============================================" +echo " Scenario: $SCENARIO_CLASS" +echo " Users/sec: $USERS_PER_SEC" +echo " Measurement: ${MEASUREMENT}s" +echo " Server: $SERVER_URL" +echo " Realm: $REALM" +echo "============================================" +echo + +java -server -Xmx1G \ + -Dserver-url="$SERVER_URL" \ + -Drealm-name="$REALM" \ + -Dclient-id=gatling \ + -Dclient-secret=setup-for-benchmark \ + -Dusers-per-sec="$USERS_PER_SEC" \ + -Dmeasurement="$MEASUREMENT" \ + -cp "$CLASSPATH" \ + io.gatling.app.Gatling \ + -rf "$RESULTS_DIR" \ + -s "$SCENARIO_CLASS" \ No newline at end of file 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 From 4a4bebe27354910e050a35bfcaaf56c2aeea45ff Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:53:43 +0300 Subject: [PATCH 091/120] refactor: make different names to services to easily differ them --- keycloak-ydb-extension/docker/docker-compose.yml | 7 ++++--- keycloak-ydb-extension/load-test/README.md | 2 +- keycloak-ydb-extension/run-keycloak-with-ydb.sh | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 932ce3a6..7e8119a2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -35,8 +35,9 @@ services: timeout: 5s retries: 5 - keycloak: + ydb-keycloak: image: quay.io/keycloak/keycloak:26.4.7 + container_name: ydb-keycloak 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 @@ -54,7 +55,7 @@ services: build: context: ../retry-proxy environment: - - TARGET_URL=http://keycloak:8080 + - TARGET_URL=http://ydb-keycloak:8080 - MAX_RETRIES=10 - BASE_DELAY_MS=50 - MAX_DELAY_MS=2000 @@ -64,4 +65,4 @@ services: networks: - keycloak depends_on: - - keycloak + - ydb-keycloak diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 9a1909fc..a8f6d261 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -36,7 +36,7 @@ This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Wait for Keycloak to start (~30-60s). Check logs: ```bash -docker compose -f docker/docker-compose.yml logs -f keycloak +docker compose -f docker/docker-compose.yml logs -f ydb-keycloak ``` Services: diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index b0052f85..c9ef6de3 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -34,4 +34,4 @@ echo " Keycloak (direct): http://localhost:8080" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" -echo "Check logs: docker compose -f docker/docker-compose.yml logs -f keycloak" +echo "Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak" From fc1ae64aac29e5ed86de2c98ba998898315a368c Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:54:42 +0300 Subject: [PATCH 092/120] fix: Make keycloak port not exposable outward --- keycloak-ydb-extension/docker/docker-compose.yml | 2 ++ keycloak-ydb-extension/run-keycloak-with-ydb.sh | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 7e8119a2..428b3de2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -38,6 +38,8 @@ services: 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 diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index c9ef6de3..6140d57d 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -30,7 +30,6 @@ echo "" echo "Stack is starting. Wait ~30-60s for Keycloak to initialize." echo "" echo " Keycloak (via retry-proxy): http://localhost:9090" -echo " Keycloak (direct): http://localhost:8080" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" From 48d3105d13ad2afc252b4dd66b4ece3b1d2065c7 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 02:57:15 +0300 Subject: [PATCH 093/120] fix: Add keycloak postgres example for load testing --- .../docker/docker-compose-pg.yml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 keycloak-ydb-extension/docker/docker-compose-pg.yml diff --git a/keycloak-ydb-extension/docker/docker-compose-pg.yml b/keycloak-ydb-extension/docker/docker-compose-pg.yml new file mode 100644 index 00000000..57d32601 --- /dev/null +++ b/keycloak-ydb-extension/docker/docker-compose-pg.yml @@ -0,0 +1,51 @@ +version: '3.4' + +volumes: + pg_data: + driver: local + +networks: + keycloak: + driver: bridge + +services: + postgres: + image: postgres:17 + container_name: postgres + volumes: + - pg_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + ports: + - '5432:5432' + networks: + - keycloak + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak"] + interval: 5s + timeout: 3s + retries: 5 + + pg-keycloak: + image: quay.io/keycloak/keycloak:26.4.7 + container_name: pg-keycloak + entrypoint: /opt/keycloak/bin/kc.sh + command: > + -v start-dev + --cache=local + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + ports: + - '9091:8080' + networks: + - keycloak + depends_on: + postgres: + condition: service_healthy From 0bca8660981551e0ade6833e0f453624f240310e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 03:04:21 +0300 Subject: [PATCH 094/120] fix: Add remote ydb infrastructure --- .../docker/conf/keycloak-remote-ydb.conf | 28 ++++++++++++ .../docker/docker-compose-remote-ydb.yml | 40 +++++++++++++++++ keycloak-ydb-extension/load-test/README.md | 43 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf create mode 100644 keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml 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..17ab5a4a --- /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) --- +# Override YDB_JDBC_URL env var to point to your remote YDB instance, e.g.: +# grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/b1g.../etn... +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/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/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index a8f6d261..11afc750 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -82,6 +82,49 @@ Delete all users from test-realm: python3 delete-all-users.py ``` +## Alternative Infrastructure + +### Keycloak + PostgreSQL + +For comparison testing against PostgreSQL: + +```bash +docker compose -f docker/docker-compose-pg.yml up -d +``` + +Services: +- Keycloak: http://localhost:9091 +- Admin credentials: `admin` / `admin` + +### Keycloak + Remote YDB + +For connecting to an external YDB instance (not in Docker Compose): + +```bash +# Start YDB separately, e.g.: +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: +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 +``` + +Services: +- Keycloak (via retry-proxy): http://localhost:9090 +- Admin credentials: `admin` / `admin` + ## Available Scenarios | Scenario | Description | From 022c57cdb5ecd5a65b606bbdc5300735e6616a6f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 03:21:23 +0300 Subject: [PATCH 095/120] docs: Update load testing readme --- keycloak-ydb-extension/load-test/README.md | 114 +++++++++++---------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 11afc750..30d489c8 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -1,6 +1,11 @@ # Load Testing -Load tests for Keycloak + YDB using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). +Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). + +Supports three 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 +- **Keycloak + PostgreSQL** — for comparison benchmarks ## Prerequisites @@ -10,7 +15,7 @@ Load tests for Keycloak + YDB using [keycloak-benchmark](https://github.com/keyc ## Quick Start -### 1. Download keycloak-benchmark +## 1. Download keycloak-benchmark ```bash ./prepare.sh @@ -23,9 +28,11 @@ To use a specific version: ./prepare.sh 26.4.0-SNAPSHOT ``` -### 2. Build and start infrastructure +## 2. Start infrastructure + +All commands below are run from the `keycloak-ydb-extension/` root. -From the `keycloak-ydb-extension/` root: +### Option A: Keycloak + Local YDB ```bash ./run-keycloak-with-ydb.sh @@ -39,92 +46,91 @@ Wait for Keycloak to start (~30-60s). Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak ``` -Services: -- Keycloak (via retry-proxy): http://localhost:9090 -- YDB Monitoring: http://localhost:8765 -- Admin credentials: `admin` / `admin` +| Service | URL | +|---------|-----| +| Keycloak (via retry-proxy) | http://localhost:9090 | +| YDB Monitoring | http://localhost:8765 | + +### Option B: Keycloak + Remote YDB -### 3. Setup test realm +Start YDB separately, e.g.: ```bash -python3 setup-test-realm.py +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 ``` -Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. - -By default connects to `http://localhost:9090`. To use a different URL: +Then start Keycloak + retry-proxy: ```bash -python3 setup-test-realm.py http://localhost:8080 +YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ + docker compose -f docker/docker-compose-remote-ydb.yml up -d --build ``` -### 4. Run load test +For a cloud YDB instance: ```bash -./run.sh [measurement-sec] [server-url] +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 ``` -Examples: +| Service | URL | +|---------|-----| +| Keycloak (via retry-proxy) | http://localhost:9090 | + +### Option C: Keycloak + PostgreSQL ```bash -./run.sh CreateUsers 30 # 30 rps, 60s measurement -./run.sh CreateUsers 30 120 # 30 rps, 120s measurement -./run.sh CreateDeleteUsers 10 60 # 10 rps, 60s +docker compose -f docker/docker-compose-pg.yml up -d ``` -Results are saved to `results/` with Gatling HTML reports. +| Service | URL | +|---------|-----| +| Keycloak | http://localhost:9091 | -### 5. Cleanup between runs +Admin credentials for all options: `admin` / `admin` -Delete all users from test-realm: +## 3. Setup test realm ```bash -python3 delete-all-users.py +python3 setup-test-realm.py # default: http://localhost:9090 +python3 setup-test-realm.py http://localhost:9091 # for PostgreSQL setup ``` -## Alternative Infrastructure - -### Keycloak + PostgreSQL +Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. -For comparison testing against PostgreSQL: +## 4. Run load test ```bash -docker compose -f docker/docker-compose-pg.yml up -d +./run.sh [measurement-sec] [server-url] ``` -Services: -- Keycloak: http://localhost:9091 -- Admin credentials: `admin` / `admin` - -### Keycloak + Remote YDB - -For connecting to an external YDB instance (not in Docker Compose): +Examples: ```bash -# Start YDB separately, e.g.: -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 +# Local YDB / Remote YDB (default server-url is http://localhost:9090) +./run.sh CreateUsers 30 +./run.sh CreateUsers 30 120 -# Then start Keycloak + retry-proxy: -YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ - docker compose -f docker/docker-compose-remote-ydb.yml up -d --build +# PostgreSQL +./run.sh CreateUsers 30 60 http://localhost:9091 ``` -For a cloud YDB instance: +Results are saved to `results/` with Gatling HTML reports. + +## 5. Cleanup between runs + +Delete all users from test-realm: ```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 +python3 delete-all-users.py # default: http://localhost:9090 +python3 delete-all-users.py http://localhost:9091 # for PostgreSQL setup ``` -Services: -- Keycloak (via retry-proxy): http://localhost:9090 -- Admin credentials: `admin` / `admin` - ## Available Scenarios | Scenario | Description | @@ -148,4 +154,4 @@ load-test/ delete-all-users.py # Deletes all users from realm lib/ # Benchmark JARs (gitignored) results/ # Gatling reports (gitignored) -``` \ No newline at end of file +``` From 810cb308e4644501c1c03a78ee742a23917ca199 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 23 Mar 2026 03:29:31 +0300 Subject: [PATCH 096/120] refactor: Optimize imports, add new line at file ends --- .../YdbConnectionProviderFactoryImpl.kt | 6 +- .../connection/YdbEntityManagerProxy.kt | 74 +++++++++---------- .../connection/YdbJpaKeycloakTransaction.kt | 42 +++++------ .../keycloak/realm/YdbRealmProviderFactory.kt | 2 +- .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 12 +-- .../docker/docker-compose-pg.yml | 2 +- .../docker/docker-compose.yml | 2 +- keycloak-ydb-extension/load-test/.gitignore | 2 +- keycloak-ydb-extension/load-test/README.md | 36 ++++----- keycloak-ydb-extension/load-test/prepare.sh | 2 +- keycloak-ydb-extension/load-test/run.sh | 2 +- keycloak-ydb-extension/retry-proxy/README.md | 29 ++++---- .../ydb/keycloak/proxy/config/ProxyConfig.kt | 2 +- .../proxy/controller/ProxyController.kt | 13 +++- .../ydb/keycloak/proxy/plugins/Routing.kt | 2 +- .../proxy/controller/ProxyControllerTest.kt | 2 +- .../proxy/service/ProxyServiceTest.kt | 2 +- .../keycloak/proxy/utils/HeaderUtilsTest.kt | 2 +- 18 files changed, 124 insertions(+), 110 deletions(-) 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 index 2d894519..82ed2a77 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -28,8 +30,6 @@ 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 com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource import java.sql.Connection import java.sql.DriverManager import java.util.* @@ -237,7 +237,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf maxLifetime = config.getLong("maxLifetime", 1800000) } dataSource = HikariDataSource(hikariConfig) - logger.infof("HikariCP pool created: maxSize=%d, minIdle=%d", hikariConfig.maximumPoolSize, hikariConfig.minimumIdle) + logger.info("HikariCP pool created: maxSize=${hikariConfig.maximumPoolSize}, minIdle=${hikariConfig.minimumIdle}") properties[AvailableSettings.JAKARTA_NON_JTA_DATASOURCE] = dataSource 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 index 16b22c38..e1f7215f 100644 --- 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 @@ -12,45 +12,45 @@ import java.lang.reflect.Proxy // TODO: add unit test 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 ydbRetryableResponse(cause) - } - throw cause - } catch (e: Exception) { - if (isYdbRetryable(e)) { - LOG.warn("YDB retryable error during ${method.name}, returning 503") - throw ydbRetryableResponse(e) - } - throw e - } + 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 ydbRetryableResponse(cause) + } + throw cause + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during ${method.name}, returning 503") + throw ydbRetryableResponse(e) + } + throw e } + } - private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( - cause.message, - cause, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) + private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( + cause.message, + cause, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) - companion object { - private val LOG: Logger = Logger.getLogger(YdbEntityManagerProxy::class.java) + 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 - } + fun create(em: EntityManager): EntityManager { + val proxy = YdbEntityManagerProxy(em) + return Proxy.newProxyInstance( + EntityManager::class.java.classLoader, + arrayOf(EntityManager::class.java), + proxy::invoke + ) as EntityManager } -} \ No newline at end of file + } +} 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 index 325e6bd4..202e1106 100644 --- 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 @@ -10,27 +10,27 @@ 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 WebApplicationException( - "YDB transaction aborted due to contention", - e, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - } - throw e - } + override fun commit() { + try { + super.commit() + } catch (e: Exception) { + if (isYdbRetryable(e)) { + LOG.warn("YDB retryable error during commit, returning 503") + throw WebApplicationException( + "YDB transaction aborted due to contention", + e, + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .header("Retry-After", "1") + .type(MediaType.APPLICATION_JSON_TYPE) + .build() + ) + } + throw e } + } - companion object { - private val LOG: Logger = Logger.getLogger(YdbJpaKeycloakTransaction::class.java) - } + companion object { + private val LOG: Logger = Logger.getLogger(YdbJpaKeycloakTransaction::class.java) + } } 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 index 5ab4f1c5..1e7fe163 100644 --- 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 @@ -2,10 +2,10 @@ 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 org.keycloak.connections.jpa.JpaConnectionProvider import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID import tech.ydb.keycloak.config.ProviderConfig.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 index cde51afb..7e797251 100644 --- 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 @@ -4,10 +4,10 @@ import tech.ydb.jdbc.exception.YdbRetryableException // TODO: add unit test fun isYdbRetryable(t: Throwable): Boolean { - var cause: Throwable? = t - while (cause != null) { - if (cause is YdbRetryableException) return true - cause = cause.cause - } - return false + var cause: Throwable? = t + while (cause != null) { + if (cause is YdbRetryableException) return true + cause = cause.cause + } + return false } diff --git a/keycloak-ydb-extension/docker/docker-compose-pg.yml b/keycloak-ydb-extension/docker/docker-compose-pg.yml index 57d32601..1f003f59 100644 --- a/keycloak-ydb-extension/docker/docker-compose-pg.yml +++ b/keycloak-ydb-extension/docker/docker-compose-pg.yml @@ -23,7 +23,7 @@ services: networks: - keycloak healthcheck: - test: ["CMD-SHELL", "pg_isready -U keycloak"] + test: [ "CMD-SHELL", "pg_isready -U keycloak" ] interval: 5s timeout: 3s retries: 5 diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index 428b3de2..f05d19c2 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -30,7 +30,7 @@ services: - MON_PORT=8765 - YDB_KAFKA_PROXY_PORT=9092 healthcheck: - test: ["CMD", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls"] + test: [ "CMD", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls" ] interval: 10s timeout: 5s retries: 5 diff --git a/keycloak-ydb-extension/load-test/.gitignore b/keycloak-ydb-extension/load-test/.gitignore index 219dec50..4ca468ac 100644 --- a/keycloak-ydb-extension/load-test/.gitignore +++ b/keycloak-ydb-extension/load-test/.gitignore @@ -1,2 +1,2 @@ lib/ -results/ \ No newline at end of file +results/ diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 30d489c8..d44c3efb 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -3,6 +3,7 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). Supports three 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 - **Keycloak + PostgreSQL** — for comparison benchmarks @@ -46,10 +47,10 @@ Wait for Keycloak to start (~30-60s). Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak ``` -| Service | URL | -|---------|-----| +| Service | URL | +|----------------------------|-----------------------| | Keycloak (via retry-proxy) | http://localhost:9090 | -| YDB Monitoring | http://localhost:8765 | +| YDB Monitoring | http://localhost:8765 | ### Option B: Keycloak + Remote YDB @@ -78,8 +79,8 @@ 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 | -|---------|-----| +| Service | URL | +|----------------------------|-----------------------| | Keycloak (via retry-proxy) | http://localhost:9090 | ### Option C: Keycloak + PostgreSQL @@ -88,8 +89,8 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. docker compose -f docker/docker-compose-pg.yml up -d ``` -| Service | URL | -|---------|-----| +| Service | URL | +|----------|-----------------------| | Keycloak | http://localhost:9091 | Admin credentials for all options: `admin` / `admin` @@ -133,16 +134,17 @@ python3 delete-all-users.py http://localhost:9091 # for PostgreSQL setup ## Available Scenarios -| Scenario | Description | -|----------|-------------| -| `CreateUsers` | Create user + List users | -| `CreateDeleteUsers` | Create user + List users + Delete user | -| `CreateClients` | Create client | -| `CreateDeleteClients` | Create client + Delete client | -| `ClientSecret` | Client credentials grant (authentication) | -| `AuthorizationCode` | Authorization code flow (authentication) | - -Full list of scenarios: [keycloak-benchmark/scenario](https://github.com/keycloak/keycloak-benchmark/tree/main/benchmark/src/main/scala/keycloak/scenario) +| Scenario | Description | +|-----------------------|-------------------------------------------| +| `CreateUsers` | Create user + List users | +| `CreateDeleteUsers` | Create user + List users + Delete user | +| `CreateClients` | Create client | +| `CreateDeleteClients` | Create client + Delete client | +| `ClientSecret` | Client credentials grant (authentication) | +| `AuthorizationCode` | Authorization code flow (authentication) | + +Full list of scenarios: +[keycloak-benchmark/scenario](https://github.com/keycloak/keycloak-benchmark/tree/main/benchmark/src/main/scala/keycloak/scenario) ## Directory Structure diff --git a/keycloak-ydb-extension/load-test/prepare.sh b/keycloak-ydb-extension/load-test/prepare.sh index 2b75a789..9df70063 100755 --- a/keycloak-ydb-extension/load-test/prepare.sh +++ b/keycloak-ydb-extension/load-test/prepare.sh @@ -43,4 +43,4 @@ cp "$DIST_DIR/lib/"*.jar "$LIB_DIR/" echo echo "Done. JARs in $LIB_DIR/:" -ls -lh "$LIB_DIR/" \ No newline at end of file +ls -lh "$LIB_DIR/" diff --git a/keycloak-ydb-extension/load-test/run.sh b/keycloak-ydb-extension/load-test/run.sh index a1249453..fc266603 100755 --- a/keycloak-ydb-extension/load-test/run.sh +++ b/keycloak-ydb-extension/load-test/run.sh @@ -69,4 +69,4 @@ java -server -Xmx1G \ -cp "$CLASSPATH" \ io.gatling.app.Gatling \ -rf "$RESULTS_DIR" \ - -s "$SCENARIO_CLASS" \ No newline at end of file + -s "$SCENARIO_CLASS" diff --git a/keycloak-ydb-extension/retry-proxy/README.md b/keycloak-ydb-extension/retry-proxy/README.md index c047e822..fde3d002 100644 --- a/keycloak-ydb-extension/retry-proxy/README.md +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -4,9 +4,12 @@ HTTP reverse proxy for Keycloak that automatically retries requests on YDB-speci ## 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. +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 @@ -20,18 +23,18 @@ 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) | +| 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) | ### 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) | +| 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/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 index a12643d4..e19fd3df 100644 --- 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 @@ -32,4 +32,4 @@ data class ProxyConfig( client = ClientConfig.fromEnv(), ) } -} \ No newline at end of file +} 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 index e3ccaa3d..53b5a6de 100644 --- 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 @@ -23,7 +23,16 @@ class ProxyController( val remoteHost = call.request.local.remoteHost val scheme = call.request.local.scheme - val result = proxyService.proxyRequest(method, path, body, contentType, call.request.headers, host, remoteHost, 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) @@ -54,4 +63,4 @@ class ProxyController( } return value } -} \ No newline at end of file +} 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 index dd7b554c..04ea9e81 100644 --- 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 @@ -12,4 +12,4 @@ fun Application.configureRouting(deps: Dependencies) { } } } -} \ No newline at end of file +} 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 index 085e59d2..49529930 100644 --- 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 @@ -124,4 +124,4 @@ class ProxyControllerTest { assertEquals("", it.bodyAsText()) } } -} \ No newline at end of file +} 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 index 2e285d82..9552cb94 100644 --- 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 @@ -224,4 +224,4 @@ class ProxyServiceTest { assertTrue(delay in 0..config.maxDelayMs) } } -} \ No newline at end of file +} 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 index 00c0eb20..437459d2 100644 --- 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 @@ -47,4 +47,4 @@ class HeaderUtilsTest { assertFalse(isHeader("Content-Type", HttpHeaders.ContentLength)) assertFalse(isHeader("Accept", HttpHeaders.Location)) } -} \ No newline at end of file +} From 1518869b5bf2c147f4d8c89122c1a65bae7dcef0 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sat, 4 Apr 2026 21:59:52 +0300 Subject: [PATCH 097/120] refactor: Delete postgres mention and add link to example in keycloak-benchmark repository --- .../docker/docker-compose-pg.yml | 51 ------------------- keycloak-ydb-extension/load-test/README.md | 24 +++------ 2 files changed, 6 insertions(+), 69 deletions(-) delete mode 100644 keycloak-ydb-extension/docker/docker-compose-pg.yml diff --git a/keycloak-ydb-extension/docker/docker-compose-pg.yml b/keycloak-ydb-extension/docker/docker-compose-pg.yml deleted file mode 100644 index 1f003f59..00000000 --- a/keycloak-ydb-extension/docker/docker-compose-pg.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: '3.4' - -volumes: - pg_data: - driver: local - -networks: - keycloak: - driver: bridge - -services: - postgres: - image: postgres:17 - container_name: postgres - volumes: - - pg_data:/var/lib/postgresql/data - environment: - POSTGRES_DB: keycloak - POSTGRES_USER: keycloak - POSTGRES_PASSWORD: keycloak - ports: - - '5432:5432' - networks: - - keycloak - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U keycloak" ] - interval: 5s - timeout: 3s - retries: 5 - - pg-keycloak: - image: quay.io/keycloak/keycloak:26.4.7 - container_name: pg-keycloak - entrypoint: /opt/keycloak/bin/kc.sh - command: > - -v start-dev - --cache=local - environment: - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak - KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: keycloak - KC_BOOTSTRAP_ADMIN_USERNAME: admin - KC_BOOTSTRAP_ADMIN_PASSWORD: admin - ports: - - '9091:8080' - networks: - - keycloak - depends_on: - postgres: - condition: service_healthy diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index d44c3efb..064bc112 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -2,11 +2,10 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling). -Supports three infrastructure configurations: +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 -- **Keycloak + PostgreSQL** — for comparison benchmarks ## Prerequisites @@ -83,23 +82,17 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. |----------------------------|-----------------------| | Keycloak (via retry-proxy) | http://localhost:9090 | -### Option C: Keycloak + PostgreSQL - -```bash -docker compose -f docker/docker-compose-pg.yml up -d -``` +Admin credentials for all options: `admin` / `admin` -| Service | URL | -|----------|-----------------------| -| Keycloak | http://localhost:9091 | +### Comparison with other databases -Admin credentials for all options: `admin` / `admin` +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 # default: http://localhost:9090 -python3 setup-test-realm.py http://localhost:9091 # for PostgreSQL setup ``` Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users. @@ -113,12 +106,8 @@ Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, Examples: ```bash -# Local YDB / Remote YDB (default server-url is http://localhost:9090) ./run.sh CreateUsers 30 ./run.sh CreateUsers 30 120 - -# PostgreSQL -./run.sh CreateUsers 30 60 http://localhost:9091 ``` Results are saved to `results/` with Gatling HTML reports. @@ -128,8 +117,7 @@ Results are saved to `results/` with Gatling HTML reports. Delete all users from test-realm: ```bash -python3 delete-all-users.py # default: http://localhost:9090 -python3 delete-all-users.py http://localhost:9091 # for PostgreSQL setup +python3 delete-all-users.py ``` ## Available Scenarios From 45e953999735a74ae49686564a416f51add01395 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Sat, 4 Apr 2026 22:51:31 +0300 Subject: [PATCH 098/120] refactor: Remove run.sh and provide examples how to run scenarios. --- .../docker/conf/keycloak.conf | 2 +- keycloak-ydb-extension/load-test/README.md | 132 ++++++++++++++++-- keycloak-ydb-extension/load-test/run.sh | 72 ---------- keycloak-ydb-extension/retry-proxy/README.md | 11 ++ 4 files changed, 129 insertions(+), 88 deletions(-) delete mode 100755 keycloak-ydb-extension/load-test/run.sh diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf index 96388166..c841b2f9 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -28,4 +28,4 @@ bootstrap-admin-password=admin # --- Proxy settings (retry-proxy in front) --- proxy-headers=forwarded http-enabled=true -hostname=http://localhost:9090 +hostname=http://0.0.0.0:9090 diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index 064bc112..bf66e636 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -35,7 +35,7 @@ All commands below are run from the `keycloak-ydb-extension/` root. ### Option A: Keycloak + Local YDB ```bash -./run-keycloak-with-ydb.sh +../run-keycloak-with-ydb.sh ``` This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Keycloak + retry-proxy). @@ -92,24 +92,123 @@ For benchmarking Keycloak with other databases (PostgreSQL, MySQL, etc.), use th ## 3. Setup test realm ```bash -python3 setup-test-realm.py # default: http://localhost:9090 +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 -./run.sh [measurement-sec] [server-url] +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 ``` -Examples: +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 -./run.sh CreateUsers 30 -./run.sh CreateUsers 30 120 +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 @@ -122,14 +221,18 @@ python3 delete-all-users.py ## Available Scenarios -| Scenario | Description | -|-----------------------|-------------------------------------------| -| `CreateUsers` | Create user + List users | -| `CreateDeleteUsers` | Create user + List users + Delete user | -| `CreateClients` | Create client | -| `CreateDeleteClients` | Create client + Delete client | -| `ClientSecret` | Client credentials grant (authentication) | -| `AuthorizationCode` | Authorization code flow (authentication) | +| 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) @@ -139,7 +242,6 @@ Full list of scenarios: ``` load-test/ prepare.sh # Downloads keycloak-benchmark from GitHub - run.sh # Runs Gatling scenario setup-test-realm.py # Creates test realm, clients, users delete-all-users.py # Deletes all users from realm lib/ # Benchmark JARs (gitignored) diff --git a/keycloak-ydb-extension/load-test/run.sh b/keycloak-ydb-extension/load-test/run.sh deleted file mode 100755 index fc266603..00000000 --- a/keycloak-ydb-extension/load-test/run.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# -# Runs a Gatling load test scenario against Keycloak. -# -# Usage: -# ./run.sh [measurement-sec] [server-url] -# -# Examples: -# ./run.sh CreateUsers 30 -# ./run.sh CreateUsers 30 60 -# ./run.sh CreateDeleteUsers 10 60 http://localhost:9090 -# -# Available scenarios: -# CreateUsers - Create + List users -# CreateDeleteUsers - Create + List + Delete users -# ClientSecret - Client credentials grant -# AuthorizationCode - Authorization code flow -# - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -LIB_DIR="$SCRIPT_DIR/lib" -RESULTS_DIR="$SCRIPT_DIR/results" - -if [ ! -d "$LIB_DIR" ] || [ -z "$(ls -A "$LIB_DIR" 2>/dev/null)" ]; then - echo "ERROR: No JARs found in $LIB_DIR" - echo "Run ./prepare.sh first" - exit 1 -fi - -SCENARIO="${1:?Usage: ./run.sh [measurement-sec] [server-url]}" -USERS_PER_SEC="${2:?Usage: ./run.sh [measurement-sec] [server-url]}" -MEASUREMENT="${3:-60}" -SERVER_URL="${4:-http://localhost:9090}" -REALM="${5:-test-realm}" - -# Resolve full scenario class name -case "$SCENARIO" in - CreateUsers|CreateDeleteUsers|CreateClients|CreateDeleteClients|CreateRealms) - SCENARIO_CLASS="keycloak.scenario.admin.$SCENARIO" - ;; - ClientSecret|AuthorizationCode) - SCENARIO_CLASS="keycloak.scenario.authentication.$SCENARIO" - ;; - *) - SCENARIO_CLASS="$SCENARIO" - ;; -esac - -CLASSPATH=$(find "$LIB_DIR" -type f -name '*.jar' | tr '\n' ':') - -echo "============================================" -echo " Scenario: $SCENARIO_CLASS" -echo " Users/sec: $USERS_PER_SEC" -echo " Measurement: ${MEASUREMENT}s" -echo " Server: $SERVER_URL" -echo " Realm: $REALM" -echo "============================================" -echo - -java -server -Xmx1G \ - -Dserver-url="$SERVER_URL" \ - -Drealm-name="$REALM" \ - -Dclient-id=gatling \ - -Dclient-secret=setup-for-benchmark \ - -Dusers-per-sec="$USERS_PER_SEC" \ - -Dmeasurement="$MEASUREMENT" \ - -cp "$CLASSPATH" \ - io.gatling.app.Gatling \ - -rf "$RESULTS_DIR" \ - -s "$SCENARIO_CLASS" diff --git a/keycloak-ydb-extension/retry-proxy/README.md b/keycloak-ydb-extension/retry-proxy/README.md index fde3d002..cfbf3757 100644 --- a/keycloak-ydb-extension/retry-proxy/README.md +++ b/keycloak-ydb-extension/retry-proxy/README.md @@ -31,6 +31,17 @@ All parameters are set via environment variables. | `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 | From cb94f45684cb7ef1af1103645ba7a75ba90f1148 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 9 Apr 2026 21:47:01 +0300 Subject: [PATCH 099/120] refactor: Remove retry-proxy implementation and leave only load tests in this branch --- .../YdbConnectionProviderFactoryImpl.kt | 8 +- .../connection/YdbEntityManagerProxy.kt | 56 ----- .../connection/YdbJpaKeycloakTransaction.kt | 36 --- .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 13 - .../docker/conf/keycloak-remote-ydb.conf | 5 - .../docker/conf/keycloak.conf | 5 - .../docker/docker-compose-remote-ydb.yml | 21 +- .../docker/docker-compose.yml | 20 +- keycloak-ydb-extension/load-test/README.md | 14 +- keycloak-ydb-extension/pom.xml | 4 - keycloak-ydb-extension/retry-proxy/Dockerfile | 10 - keycloak-ydb-extension/retry-proxy/README.md | 51 ---- keycloak-ydb-extension/retry-proxy/pom.xml | 170 ------------- .../tech/ydb/keycloak/proxy/Dependencies.kt | 14 -- .../tech/ydb/keycloak/proxy/RetryProxy.kt | 19 -- .../ydb/keycloak/proxy/client/ProxyClient.kt | 17 -- .../ydb/keycloak/proxy/config/ProxyConfig.kt | 35 --- .../proxy/controller/ProxyController.kt | 66 ----- .../ydb/keycloak/proxy/plugins/Routing.kt | 15 -- .../keycloak/proxy/service/ProxyService.kt | 121 ---------- .../ydb/keycloak/proxy/utils/HeaderUtils.kt | 19 -- .../proxy/controller/ProxyControllerTest.kt | 127 ---------- .../proxy/service/ProxyServiceTest.kt | 227 ------------------ .../keycloak/proxy/utils/HeaderUtilsTest.kt | 50 ---- .../run-keycloak-with-ydb.sh | 9 +- 25 files changed, 16 insertions(+), 1116 deletions(-) delete mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt delete mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt delete mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/Dockerfile delete mode 100644 keycloak-ydb-extension/retry-proxy/README.md delete mode 100644 keycloak-ydb-extension/retry-proxy/pom.xml delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt delete mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt 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 index 82ed2a77..3a6bfe57 100644 --- 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 @@ -12,6 +12,7 @@ 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.JpaKeycloakTransaction import org.keycloak.connections.jpa.support.EntityManagerProxy import org.keycloak.connections.jpa.updater.JpaUpdaterProvider import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY @@ -58,13 +59,10 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf emf.createEntityManager(SYNCHRONIZED) } - val keycloakEm = EntityManagerProxy.create(session, em, true) - val ydbEm = YdbEntityManagerProxy.create(keycloakEm) - if (!jtaEnabled) { - session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm)) + session.transactionManager.enlist(JpaKeycloakTransaction(em)) } - return DefaultJpaConnectionProvider(ydbEm) + return DefaultJpaConnectionProvider(EntityManagerProxy.create(session, em, true)) } override fun init(scope: Config.Scope) { 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 deleted file mode 100644 index e1f7215f..00000000 --- a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt +++ /dev/null @@ -1,56 +0,0 @@ -package tech.ydb.keycloak.connection - -import jakarta.persistence.EntityManager -import jakarta.ws.rs.WebApplicationException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response -import org.jboss.logging.Logger -import tech.ydb.keycloak.utils.isYdbRetryable -import java.lang.reflect.InvocationTargetException -import java.lang.reflect.Method -import java.lang.reflect.Proxy - -// TODO: add unit test -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 ydbRetryableResponse(cause) - } - throw cause - } catch (e: Exception) { - if (isYdbRetryable(e)) { - LOG.warn("YDB retryable error during ${method.name}, returning 503") - throw ydbRetryableResponse(e) - } - throw e - } - } - - private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( - cause.message, - cause, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - - 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 deleted file mode 100644 index 202e1106..00000000 --- a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt +++ /dev/null @@ -1,36 +0,0 @@ -package tech.ydb.keycloak.connection - -import jakarta.persistence.EntityManager -import jakarta.ws.rs.WebApplicationException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response -import org.jboss.logging.Logger -import org.keycloak.connections.jpa.JpaKeycloakTransaction -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 WebApplicationException( - "YDB transaction aborted due to contention", - e, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - } - 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/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt deleted file mode 100644 index 7e797251..00000000 --- a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt +++ /dev/null @@ -1,13 +0,0 @@ -package tech.ydb.keycloak.utils - -import tech.ydb.jdbc.exception.YdbRetryableException - -// TODO: add unit test -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/docker/conf/keycloak-remote-ydb.conf b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf index 17ab5a4a..ba0e4166 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf @@ -21,8 +21,3 @@ 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 index c841b2f9..ff0b5d26 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak.conf @@ -24,8 +24,3 @@ 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 index 944cfed3..63ae01b6 100644 --- a/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml +++ b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml @@ -8,8 +8,8 @@ services: remote-ydb-keycloak: image: quay.io/keycloak/keycloak:26.4.7 container_name: remote-ydb-keycloak - expose: - - '8080' + ports: + - '9090: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 @@ -21,20 +21,3 @@ services: - 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 index f05d19c2..a6d2daf6 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -38,8 +38,6 @@ services: 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 @@ -47,24 +45,10 @@ services: command: > -v start-dev --cache=local - networks: - - keycloak - depends_on: - ydb: - condition: service_started - - 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 + ydb: + condition: service_started diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md index bf66e636..4343024e 100644 --- a/keycloak-ydb-extension/load-test/README.md +++ b/keycloak-ydb-extension/load-test/README.md @@ -4,8 +4,8 @@ Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/k 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 +- **Keycloak + Local YDB** — YDB in Docker, Keycloak exposed directly +- **Keycloak + Remote YDB** — external YDB instance, Keycloak exposed directly ## Prerequisites @@ -38,7 +38,7 @@ All commands below are run from the `keycloak-ydb-extension/` root. ../run-keycloak-with-ydb.sh ``` -This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Keycloak + retry-proxy). +This builds core, copies the JAR, and starts Docker Compose (YDB + Keycloak). Wait for Keycloak to start (~30-60s). Check logs: @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml logs -f ydb-keycloak | Service | URL | |----------------------------|-----------------------| -| Keycloak (via retry-proxy) | http://localhost:9090 | +| Keycloak | http://localhost:9090 | | YDB Monitoring | http://localhost:8765 | ### Option B: Keycloak + Remote YDB @@ -64,7 +64,7 @@ docker run -d --rm --name ydb-local -h localhost \ ydbplatform/local-ydb:latest ``` -Then start Keycloak + retry-proxy: +Then start Keycloak: ```bash YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \ @@ -80,7 +80,7 @@ YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/. | Service | URL | |----------------------------|-----------------------| -| Keycloak (via retry-proxy) | http://localhost:9090 | +| Keycloak | http://localhost:9090 | Admin credentials for all options: `admin` / `admin` @@ -187,7 +187,7 @@ java -server -Xmx1G \ 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). +Also make sure `hostname` in `docker/conf/keycloak.conf` matches this address. By default, users `user-0`, `user-1`, ... with passwords `user-0-password`, `user-1-password`, ... are used (created by `setup-test-realm.py`). diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index e2dd96fa..a64450f9 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -15,7 +15,6 @@ core test - retry-proxy @@ -37,9 +36,6 @@ 7.0.2 0.9.1 - - 3.4.1 - 1.5.16 diff --git a/keycloak-ydb-extension/retry-proxy/Dockerfile b/keycloak-ydb-extension/retry-proxy/Dockerfile deleted file mode 100644 index 2b38ec93..00000000 --- a/keycloak-ydb-extension/retry-proxy/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index cfbf3757..00000000 --- a/keycloak-ydb-extension/retry-proxy/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index d0fe3bc3..00000000 --- a/keycloak-ydb-extension/retry-proxy/pom.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - 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-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 deleted file mode 100644 index 31a3ac8f..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 9a3cb593..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index a7b6ec57..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index e19fd3df..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 53b5a6de..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt +++ /dev/null @@ -1,66 +0,0 @@ -package tech.ydb.keycloak.proxy.controller - -import io.ktor.http.* -import io.ktor.server.application.* -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.local.remoteHost - val scheme = call.request.local.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}" - - result.headers.forEach { name, values -> - if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) { - values.forEach { value -> - response.header(name, rewriteInternalUrl(name, value, originalHost)) - } - } - } - - respondBytes(result.body, result.contentType, result.status) - } - - private fun rewriteInternalUrl(name: String, value: String, originalHost: String): String { - if (isHeader(name, HttpHeaders.Location)) { - return value.replace(config.targetUrl, "http://$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 deleted file mode 100644 index 04ea9e81..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt +++ /dev/null @@ -1,15 +0,0 @@ -package tech.ydb.keycloak.proxy.plugins - -import io.ktor.server.application.* -import io.ktor.server.routing.* -import tech.ydb.keycloak.proxy.Dependencies - -fun Application.configureRouting(deps: Dependencies) { - 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 deleted file mode 100644 index 4de789d9..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt +++ /dev/null @@ -1,121 +0,0 @@ -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.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() - - val isRetryable = response.status.value == 503 && String(responseBody).contains("ydb_retryable") - - if (isRetryable) { - 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 deleted file mode 100644 index 1f59cae1..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt +++ /dev/null @@ -1,19 +0,0 @@ -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/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 deleted file mode 100644 index 49529930..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -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").let { - assertEquals(HttpStatusCode.Found, it.status) - assertTrue(it.headers[HttpHeaders.Location]!!.contains("/realms/master")) - assertFalse(it.headers[HttpHeaders.Location]!!.contains("backend:8080")) - } - } - - @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 deleted file mode 100644 index 9552cb94..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt +++ /dev/null @@ -1,227 +0,0 @@ -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 deleted file mode 100644 index 437459d2..00000000 --- a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -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/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh index 6140d57d..68751db6 100755 --- a/keycloak-ydb-extension/run-keycloak-with-ydb.sh +++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh @@ -18,18 +18,13 @@ 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) ===" +echo "=== Starting Docker Compose (YDB + Keycloak) ===" 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 " Keycloak: http://localhost:9090" echo " YDB Monitoring: http://localhost:8765" echo " Admin credentials: admin / admin" echo "" From bbee2395b20827951dc971e0845ba9ed5d6f3d3a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Mon, 18 May 2026 18:54:44 +0300 Subject: [PATCH 100/120] chore: Format file --- .../ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt | 1 - 1 file changed, 1 deletion(-) 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 index 7ce01020..19ef8d7b 100644 --- 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 @@ -222,7 +222,6 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf private fun buildPropertiesFromScope(): MutableMap { val properties = mutableMapOf() - val hikariConfig = HikariConfig().apply { this.jdbcUrl = jdbcUrl driverClassName = YdbDriver::class.java.name From 0328a1c4d46fc281af27462e48e376096e33b9c0 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 19 May 2026 15:54:35 +0300 Subject: [PATCH 101/120] test: Add unit test of YdbEntityManagerProxy --- .../connection/YdbEntityManagerProxy.kt | 1 - .../connection/YdbJpaKeycloakTransaction.kt | 2 +- .../tech/ydb/keycloak/utils/YdbErrorUtils.kt | 1 - .../connection/YdbEntityManagerProxyTest.kt | 73 +++++++++++++++ .../YdbJpaKeycloakTransactionTest.kt | 88 +++++++++++++++++++ .../testsupport/YdbRetryableExceptionUtil.kt | 14 +++ .../ydb/keycloak/utils/YdbErrorUtilsTest.kt | 38 ++++++++ 7 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt create mode 100644 keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt create mode 100644 keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt create mode 100644 keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt 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 index e1f7215f..88e0de39 100644 --- 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 @@ -10,7 +10,6 @@ import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.lang.reflect.Proxy -// TODO: add unit test class YdbEntityManagerProxy(private val em: EntityManager) { private fun invoke(proxy: Any, method: Method, args: Array?): Any? { try { 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 index 202e1106..88031ce3 100644 --- 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 @@ -20,7 +20,7 @@ class YdbJpaKeycloakTransaction(em: EntityManager) : JpaKeycloakTransaction(em) "YDB transaction aborted due to contention", e, Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") + .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted, please retry"}""") .header("Retry-After", "1") .type(MediaType.APPLICATION_JSON_TYPE) .build() 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 index 7e797251..8e003912 100644 --- 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 @@ -2,7 +2,6 @@ package tech.ydb.keycloak.utils import tech.ydb.jdbc.exception.YdbRetryableException -// TODO: add unit test fun isYdbRetryable(t: Throwable): Boolean { var cause: Throwable? = t while (cause != null) { 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..1f2dc0a9 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt @@ -0,0 +1,73 @@ +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("1", ex.response.getHeaderString("Retry-After")) + 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..68b70f79 --- /dev/null +++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt @@ -0,0 +1,88 @@ +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("1", ex.response.getHeaderString("Retry-After")) + 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/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")))) + } +} From 46cb384e82c1b721f5326a026aaeee249714051a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 19 May 2026 15:55:05 +0300 Subject: [PATCH 102/120] fix: Use jdbcUrl from config --- .../ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b8417d72..9f62c040 100644 --- 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 @@ -225,7 +225,7 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf val properties = mutableMapOf() val hikariConfig = HikariConfig().apply { - this.jdbcUrl = jdbcUrl + jdbcUrl = this@YdbConnectionProviderFactoryImpl.jdbcUrl driverClassName = YdbDriver::class.java.name maximumPoolSize = config.getInt("poolSize", 50) minimumIdle = config.getInt("minIdle", 10) From c781adba691d4e7fc74b1dd34ffa1199106cdb59 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 19 May 2026 17:48:31 +0300 Subject: [PATCH 103/120] fix: Add async index to USER_ENTITY --- .../changesets/2026-02-07-16-00-create-user-entity-table.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 93711a53..e210c270 100644 --- 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 @@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS USER_ENTITY `NOT_BEFORE` Int32 NOT NULL DEFAULT 0, INDEX idx_user_email GLOBAL ON (EMAIL), - INDEX idx_user_service_account GLOBAL ON (REALM_ID, SERVICE_ACCOUNT_CLIENT_LINK), + 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) From 6ca891b9f0c72abdea2875e616338fbf544eb012 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Tue, 19 May 2026 19:59:05 +0300 Subject: [PATCH 104/120] Add another ydb retryable exception handling --- keycloak-ydb-extension/core/pom.xml | 5 ++ .../connection/YdbEntityManagerProxy.kt | 18 ++----- .../connection/YdbJpaKeycloakTransaction.kt | 14 ++--- .../error/YdbRetryableExceptionMappers.kt | 41 +++++++++++++++ .../ydb/keycloak/liquibase/YdbLockService.kt | 24 ++++++++- .../keycloak/utils/YdbRetryableResponses.kt | 24 +++++++++ .../connection/YdbEntityManagerProxyTest.kt | 1 - .../YdbJpaKeycloakTransactionTest.kt | 1 - .../error/YdbRetryableExceptionMappersTest.kt | 51 +++++++++++++++++++ .../utils/YdbRetryableResponsesTest.kt | 23 +++++++++ 10 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt create mode 100644 keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt create mode 100644 keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt create mode 100644 keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt diff --git a/keycloak-ydb-extension/core/pom.xml b/keycloak-ydb-extension/core/pom.xml index 16092c55..f3f91aeb 100644 --- a/keycloak-ydb-extension/core/pom.xml +++ b/keycloak-ydb-extension/core/pom.xml @@ -210,5 +210,10 @@ 6.1.0 test + + org.jboss.resteasy + resteasy-core + test + 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 index 88e0de39..c6a8f52e 100644 --- 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 @@ -1,10 +1,8 @@ package tech.ydb.keycloak.connection import jakarta.persistence.EntityManager -import jakarta.ws.rs.WebApplicationException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response 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 @@ -18,28 +16,18 @@ class YdbEntityManagerProxy(private val em: EntityManager) { val cause = e.cause ?: throw e if (isYdbRetryable(cause)) { LOG.warn("YDB retryable error during ${method.name}, returning 503") - throw ydbRetryableResponse(cause) + throw YdbRetryableResponses.toWebApplicationException(cause) } throw cause } catch (e: Exception) { if (isYdbRetryable(e)) { LOG.warn("YDB retryable error during ${method.name}, returning 503") - throw ydbRetryableResponse(e) + throw YdbRetryableResponses.toWebApplicationException(e) } throw e } } - private fun ydbRetryableResponse(cause: Throwable) = WebApplicationException( - cause.message, - cause, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted due to contention, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() - ) - companion object { private val LOG: Logger = Logger.getLogger(YdbEntityManagerProxy::class.java) 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 index 88031ce3..2b3db18b 100644 --- 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 @@ -1,11 +1,9 @@ package tech.ydb.keycloak.connection import jakarta.persistence.EntityManager -import jakarta.ws.rs.WebApplicationException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response 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) { @@ -16,14 +14,10 @@ class YdbJpaKeycloakTransaction(em: EntityManager) : JpaKeycloakTransaction(em) } catch (e: Exception) { if (isYdbRetryable(e)) { LOG.warn("YDB retryable error during commit, returning 503") - throw WebApplicationException( - "YDB transaction aborted due to contention", + throw YdbRetryableResponses.toWebApplicationException( e, - Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity("""{"error":"ydb_retryable","error_description":"Transaction aborted, please retry"}""") - .header("Retry-After", "1") - .type(MediaType.APPLICATION_JSON_TYPE) - .build() + "YDB transaction aborted", + YdbRetryableResponses.TRANSACTION_DESCRIPTION, ) } throw e 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/YdbLockService.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt index 2300408d..80a5a752 100644 --- 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 @@ -4,8 +4,8 @@ import liquibase.Scope import liquibase.exception.DatabaseException import liquibase.executor.ExecutorService import liquibase.lockservice.StandardLockService -import liquibase.statement.core.CreateDatabaseChangeLogLockTableStatement 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 @@ -26,7 +26,7 @@ class YdbLockService : StandardLockServiceYdb() { if (!isDatabaseChangeLogLockTableCreated) { try { log.trace("Create Database Lock Table") - executor.execute(CreateDatabaseChangeLogLockTableStatement()) + 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) @@ -178,6 +178,26 @@ class YdbLockService : StandardLockServiceYdb() { } } + /** + * 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/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/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt index 1f2dc0a9..9ee31df9 100644 --- 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 @@ -24,7 +24,6 @@ class YdbEntityManagerProxyTest { val ex = assertThrows(WebApplicationException::class.java) { proxy.flush() } assertEquals(503, ex.response.status) - assertEquals("1", ex.response.getHeaderString("Retry-After")) assertEquals("application", ex.response.mediaType?.type) assertEquals("json", ex.response.mediaType?.subtype) assertSame(exception, ex.cause) 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 index 68b70f79..35b276f5 100644 --- 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 @@ -32,7 +32,6 @@ class YdbJpaKeycloakTransactionTest { val ex = assertThrows(WebApplicationException::class.java) { transaction.commit() } assertEquals(503, ex.response.status) - assertEquals("1", ex.response.getHeaderString("Retry-After")) assertEquals("application", ex.response.mediaType?.type) assertEquals("json", ex.response.mediaType?.subtype) assertSame(retryable, ex.cause) 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/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)) + } +} From f10ac44ced5a3ed1f6f08029407eff81b7b4c1c1 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 14:41:41 +0300 Subject: [PATCH 105/120] chore: Add indexes as it is in PG. Remove redundant indexes. Add missing tables --- .../2026-02-06-06-00-create-migration-model-table.sql | 3 +++ .../2026-02-06-21-24-create-client-attributes-table.sql | 1 + ...26-02-07-16-26-create-offline-client-session-table.sql | 5 ++--- .../2026-02-07-17-04-create-user-consent-table.sql | 1 - .../2026-02-07-17-17-create-group-attribute-table.sql | 2 +- ...26-02-08-19-54-create-resource-server-policy-table.sql | 2 -- ...-02-08-19-56-create-resource-server-resource-table.sql | 1 - ...-08-20-01-create-resource-server-perm-ticket-table.sql | 4 ---- .../2026-05-20-01-create-associated-policy-table.sql | 8 ++++++++ .../2026-05-20-02-create-scope-policy-table.sql | 8 ++++++++ .../core/src/main/resources/ydb/db.changelog-master.xml | 6 ++++++ 11 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql create mode 100644 keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql 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 index 1b8a6293..9a805853 100644 --- 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 @@ -4,5 +4,8 @@ CREATE TABLE IF NOT EXISTS MIGRATION_MODEL `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 index d164d690..4e3c0396 100644 --- 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 @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS CLIENT_ATTRIBUTES `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... PRIMARY KEY (CLIENT_ID, NAME) 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 index 8a09167d..a5f8eafa 100644 --- 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 @@ -9,8 +9,7 @@ CREATE TABLE IF NOT EXISTS OFFLINE_CLIENT_SESSION `EXTERNAL_CLIENT_ID` Utf8 NOT NULL DEFAULT "local", `VERSION` Int32 DEFAULT 0, - INDEX idx_offl_client_sess_client GLOBAL ON (CLIENT_ID), - INDEX idx_offl_client_sess_user GLOBAL ON (USER_SESSION_ID), - INDEX idx_offl_client_sess_ext_client GLOBAL ON (EXTERNAL_CLIENT_ID), + 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-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 index 5bf0d047..649c16b8 100644 --- 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 @@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS USER_CONSENT `EXTERNAL_CLIENT_ID` Utf8, INDEX idx_user_consent GLOBAL ON (USER_ID), - INDEX idx_user_consent_client GLOBAL ON (CLIENT_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), 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 index f9a82f37..c42ba38e 100644 --- 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 @@ -7,6 +7,6 @@ CREATE TABLE IF NOT EXISTS GROUP_ATTRIBUTE 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), + -- 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-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 index 4f8b5dc2..e5e27023 100644 --- 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 @@ -10,8 +10,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_POLICY `OWNER` Utf8, INDEX idx_res_serv_pol_res_serv GLOBAL ON (RESOURCE_SERVER_ID), - INDEX idx_res_serv_pol_type GLOBAL ON (TYPE), - INDEX idx_res_serv_pol_owner GLOBAL ON (OWNER), -- 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), 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 index ae28bc93..60acb552 100644 --- 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 @@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_RESOURCE `DISPLAY_NAME` Utf8, INDEX idx_res_srv_res_res_srv GLOBAL ON (RESOURCE_SERVER_ID), - INDEX idx_res_srv_res_owner GLOBAL ON (OWNER), -- 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), 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 index 28c8dbd1..93964d43 100644 --- 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 @@ -12,10 +12,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_PERM_TICKET INDEX idx_perm_ticket_requester GLOBAL ON (REQUESTER), INDEX idx_perm_ticket_owner GLOBAL ON (OWNER), - INDEX idx_perm_ticket_resource GLOBAL ON (RESOURCE_ID), - INDEX idx_perm_ticket_resource_server GLOBAL ON (RESOURCE_SERVER_ID), - INDEX idx_perm_ticket_policy GLOBAL ON (POLICY_ID), - INDEX idx_perm_ticket_scope GLOBAL ON (SCOPE_ID), -- 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), 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..4abe3738 --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql @@ -0,0 +1,8 @@ +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), + 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..4df5e31f --- /dev/null +++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql @@ -0,0 +1,8 @@ +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), + 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 index 35b45f02..0cca7e61 100644 --- 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 @@ -250,4 +250,10 @@ + + + + + + From 20f45eacbca8673484e743bd9daa01b780a96b97 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 15:24:19 +0300 Subject: [PATCH 106/120] chore: Remove redundant indexes --- ...2026-02-07-16-06-create-identity-provider-config-table.sql | 1 - ...026-02-07-16-19-create-client-auth-flow-bindings-table.sql | 2 -- ...026-02-07-16-21-create-client-node-registrations-table.sql | 2 -- .../2026-02-07-16-37-create-user-group-membership-table.sql | 1 - .../2026-02-07-16-40-create-keycloak-group-table.sql | 2 -- .../2026-02-07-17-08-create-federated-identity-table.sql | 1 - .../2026-02-07-17-09-create-fed-user-consent-table.sql | 1 - ...026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql | 2 -- .../2026-02-07-17-11-create-fed-user-credential-table.sql | 1 - ...026-02-07-17-12-create-fed-user-group-membership-table.sql | 2 -- ...2026-02-07-17-12-create-fed-user-required-action-table.sql | 1 - .../2026-02-07-17-13-create-fed-user-role-mapping-table.sql | 2 -- .../2026-02-07-17-14-create-federated-user-table.sql | 3 --- .../changesets/2026-02-07-17-15-create-broker-link-table.sql | 4 ---- .../2026-02-07-17-16-create-fed-user-attribute-table.sql | 2 -- .../2026-02-07-17-18-create-group-role-mapping-table.sql | 1 - .../2026-02-08-19-57-create-resource-attribute-table.sql | 2 -- .../2026-02-08-19-58-create-resource-policy-table.sql | 1 - .../2026-02-08-20-00-create-resource-scope-table.sql | 1 - .../2026-02-08-20-02-create-resource-uris-table.sql | 2 -- .../2026-02-15-13-45-create-policy-config-table.sql | 2 -- .../2026-02-15-15-18-create-idp-mapper-config-table.sql | 2 -- ...2026-02-15-15-23-create-user-federation-provider-table.sql | 2 -- .../2026-02-15-15-24-create-user-federation-config-table.sql | 2 -- .../2026-02-15-15-25-create-user-federation-mapper-table.sql | 1 - ...02-15-15-29-create-user-federation-mapper-config-table.sql | 2 -- .../2026-03-06-00-21-create-admin-event-entity-table.sql | 4 ---- 27 files changed, 49 deletions(-) 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 index 4d48924c..c1a9b52c 100644 --- 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 @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_CONFIG `VALUE` Utf8, `NAME` Utf8 NOT NULL, - INDEX idx_idp_config_provider GLOBAL ON (IDENTITY_PROVIDER_ID), -- 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-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 index 20ab6df2..b891dfd8 100644 --- 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 @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS CLIENT_AUTH_FLOW_BINDINGS `FLOW_ID` Utf8, `BINDING_NAME` Utf8 NOT NULL, - INDEX idx_cl_auth_flow_client GLOBAL ON (CLIENT_ID), - INDEX idx_cl_auth_flow_flow GLOBAL ON (FLOW_ID), -- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), -- FOREIGN KEY (FLOW_ID) REFERENCES AUTHENTICATION_FLOW (ID), PRIMARY KEY (CLIENT_ID, BINDING_NAME) 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 index 22134d31..7e058850 100644 --- 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 @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS CLIENT_NODE_REGISTRATIONS `VALUE` Int32, `NAME` Utf8 NOT NULL, - INDEX idx_cl_node_reg_client GLOBAL ON (CLIENT_ID), - INDEX idx_cl_node_reg_name GLOBAL ON (NAME), -- 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-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 index 6eff3d61..518341f6 100644 --- 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 @@ -5,7 +5,6 @@ CREATE TABLE IF NOT EXISTS USER_GROUP_MEMBERSHIP `MEMBERSHIP_TYPE` Utf8 NOT NULL, INDEX idx_user_group_mapping GLOBAL ON (USER_ID), - INDEX idx_user_group_group GLOBAL ON (GROUP_ID), -- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), -- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), PRIMARY KEY (GROUP_ID, USER_ID) 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 index dc48ff3e..7c059b0a 100644 --- 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 @@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS KEYCLOAK_GROUP `TYPE` Int32 NOT NULL DEFAULT 0, `DESCRIPTION` Utf8, - INDEX idx_keycloak_group_parent GLOBAL ON (PARENT_GROUP), - INDEX idx_keycloak_group_realm GLOBAL ON (REALM_ID), -- CONSTRAINT `SIBLING_NAMES` GLOBAL UNIQUE ON (REALM_ID, PARENT_GROUP, NAME), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), -- FOREIGN KEY (PARENT_GROUP) REFERENCES KEYCLOAK_GROUP (ID), 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 index de91798a..5b7510bf 100644 --- 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 @@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS FEDERATED_IDENTITY INDEX idx_fedidentity_user GLOBAL ON (USER_ID), INDEX idx_fedidentity_feduser GLOBAL ON (FEDERATED_USER_ID), - INDEX idx_fedidentity_provider GLOBAL ON (IDENTITY_PROVIDER), -- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), PRIMARY KEY (IDENTITY_PROVIDER, USER_ID) 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 index fef14ad0..a2663e47 100644 --- 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 @@ -13,7 +13,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_CONSENT 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), - INDEX idx_fed_consent_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-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 index 1f43b456..21cb32c8 100644 --- 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 @@ -3,8 +3,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_CONSENT_CL_SCOPE `USER_CONSENT_ID` Utf8 NOT NULL, `SCOPE_ID` Utf8 NOT NULL, - INDEX idx_fed_consent_cl_scope_consent GLOBAL ON (USER_CONSENT_ID), - INDEX idx_fed_consent_cl_scope_scope GLOBAL ON (SCOPE_ID), -- FOREIGN KEY (USER_CONSENT_ID) REFERENCES FED_USER_CONSENT (ID), -- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID) 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 index 9139ecf6..e10c8246 100644 --- 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 @@ -14,7 +14,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_CREDENTIAL INDEX idx_fu_credential GLOBAL ON (USER_ID, TYPE), INDEX idx_fu_credential_ru GLOBAL ON (REALM_ID, USER_ID), - INDEX idx_fed_credential_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-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 index f6cf32f2..3d6e7a71 100644 --- 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 @@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_GROUP_MEMBERSHIP INDEX idx_fu_group_membership GLOBAL ON (USER_ID, GROUP_ID), INDEX idx_fu_group_membership_ru GLOBAL ON (REALM_ID, USER_ID), - INDEX idx_fed_group_membership_group GLOBAL ON (GROUP_ID), - INDEX idx_fed_group_membership_realm GLOBAL ON (REALM_ID), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), -- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), PRIMARY KEY (GROUP_ID, USER_ID) 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 index 5ebf972f..d943ae68 100644 --- 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 @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_REQUIRED_ACTION INDEX idx_fu_required_action GLOBAL ON (USER_ID, REQUIRED_ACTION), INDEX idx_fu_required_action_ru GLOBAL ON (REALM_ID, USER_ID), - INDEX idx_fed_req_action_realm GLOBAL ON (REALM_ID), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (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 index dd8c6509..ef3debb0 100644 --- 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 @@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_ROLE_MAPPING INDEX idx_fu_role_mapping GLOBAL ON (USER_ID, ROLE_ID), INDEX idx_fu_role_mapping_ru GLOBAL ON (REALM_ID, USER_ID), - INDEX idx_fed_role_mapping_role GLOBAL ON (ROLE_ID), - INDEX idx_fed_role_mapping_realm GLOBAL ON (REALM_ID), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), -- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), PRIMARY KEY (ROLE_ID, USER_ID) 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 index 45171a97..8ff82ef6 100644 --- 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 @@ -4,8 +4,5 @@ CREATE TABLE IF NOT EXISTS FEDERATED_USER `STORAGE_PROVIDER_ID` Utf8, `REALM_ID` Utf8 NOT NULL, - INDEX idx_federated_user_realm GLOBAL ON (REALM_ID), - INDEX idx_federated_user_storage GLOBAL ON (STORAGE_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-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 index 9468c5ee..84aab4d2 100644 --- 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 @@ -8,10 +8,6 @@ CREATE TABLE IF NOT EXISTS BROKER_LINK `TOKEN` Utf8, `USER_ID` Utf8 NOT NULL, - INDEX idx_broker_link_realm GLOBAL ON (REALM_ID), - INDEX idx_broker_link_user GLOBAL ON (USER_ID), - INDEX idx_broker_link_provider GLOBAL ON (IDENTITY_PROVIDER), - INDEX idx_broker_link_broker_user GLOBAL ON (BROKER_USER_ID), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (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-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 index 6023a20c..ca836699 100644 --- 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 @@ -13,8 +13,6 @@ CREATE TABLE IF NOT EXISTS FED_USER_ATTRIBUTE 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), - INDEX idx_fed_user_attr_realm GLOBAL ON (REALM_ID), - INDEX idx_fed_user_attr_user GLOBAL ON (USER_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-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 index 12a7da1c..63a6cddb 100644 --- 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 @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS GROUP_ROLE_MAPPING `GROUP_ID` Utf8 NOT NULL, INDEX idx_group_role_mapp_group GLOBAL ON (GROUP_ID), - INDEX idx_group_role_mapp_role GLOBAL ON (ROLE_ID), -- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), -- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID), PRIMARY KEY (ROLE_ID, GROUP_ID) 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 index 75b054c2..07863a1e 100644 --- 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 @@ -5,8 +5,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_ATTRIBUTE `VALUE` Utf8, `RESOURCE_ID` Utf8 NOT NULL, - INDEX idx_resource_attr_resource GLOBAL ON (RESOURCE_ID), - INDEX idx_resource_attr_name_value GLOBAL ON (NAME, VALUE), -- 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 index 0f57152a..7b594e05 100644 --- 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 @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_POLICY `POLICY_ID` Utf8 NOT NULL, INDEX idx_res_policy_policy GLOBAL ON (POLICY_ID), - INDEX idx_res_policy_resource GLOBAL ON (RESOURCE_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) 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 index 3ddeaded..ce6f9b30 100644 --- 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 @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_SCOPE `SCOPE_ID` Utf8 NOT NULL, INDEX idx_res_scope_scope GLOBAL ON (SCOPE_ID), - INDEX idx_res_scope_resource GLOBAL ON (RESOURCE_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) 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 index 46237b8c..1167b7b7 100644 --- 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 @@ -3,8 +3,6 @@ CREATE TABLE IF NOT EXISTS RESOURCE_URIS `RESOURCE_ID` Utf8 NOT NULL, `VALUE` Utf8 NOT NULL, - INDEX idx_resource_uris_resource GLOBAL ON (RESOURCE_ID), - INDEX idx_resource_uris_value GLOBAL ON (VALUE), -- 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 index 217936f1..27866df4 100644 --- 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 @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS POLICY_CONFIG `NAME` Utf8 NOT NULL, `VALUE` Utf8, - INDEX idx_policy_config_policy GLOBAL ON (POLICY_ID), - INDEX idx_policy_config_name GLOBAL ON (NAME), -- 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 index fe2d5dac..21f71a52 100644 --- 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 @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS IDP_MAPPER_CONFIG `VALUE` Utf8, `NAME` Utf8 NOT NULL, - INDEX idx_idp_mapper_config_mapper GLOBAL ON (IDP_MAPPER_ID), - INDEX idx_idp_mapper_config_name GLOBAL ON (NAME), -- 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-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 index dd0254f4..ca8b018f 100644 --- 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 @@ -10,8 +10,6 @@ CREATE TABLE IF NOT EXISTS USER_FEDERATION_PROVIDER `REALM_ID` Utf8, INDEX idx_usr_fed_prv_realm GLOBAL ON (REALM_ID), - INDEX idx_usr_fed_prv_name GLOBAL ON (PROVIDER_NAME), - INDEX idx_usr_fed_prv_priority GLOBAL ON (PRIORITY), -- 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 index f8ca41a0..ae7a5d95 100644 --- 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 @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS USER_FEDERATION_CONFIG `VALUE` Utf8, `NAME` Utf8 NOT NULL, - INDEX idx_user_fed_config_provider GLOBAL ON (USER_FEDERATION_PROVIDER_ID), - INDEX idx_user_fed_config_name GLOBAL ON (NAME), -- 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 index c532d015..bd167b1a 100644 --- 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 @@ -8,7 +8,6 @@ CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER INDEX idx_usr_fed_map_fed_prv GLOBAL ON (FEDERATION_PROVIDER_ID), INDEX idx_usr_fed_map_realm GLOBAL ON (REALM_ID), - INDEX idx_usr_fed_map_type GLOBAL ON (FEDERATION_MAPPER_TYPE), -- FOREIGN KEY (FEDERATION_PROVIDER_ID) REFERENCES USER_FEDERATION_PROVIDER (ID), -- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), PRIMARY KEY (ID) 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 index 7d53e774..ae243527 100644 --- 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 @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER_CONFIG `VALUE` Utf8, `NAME` Utf8 NOT NULL, - INDEX idx_usr_fed_map_cfg_mapper GLOBAL ON (USER_FEDERATION_MAPPER_ID), - INDEX idx_usr_fed_map_cfg_name GLOBAL ON (NAME), -- 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 index e14ca3f1..40e29ab3 100644 --- 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 @@ -15,9 +15,5 @@ CREATE TABLE IF NOT EXISTS ADMIN_EVENT_ENTITY `DETAILS_JSON` Utf8, INDEX idx_admin_event_time GLOBAL ON (REALM_ID, ADMIN_EVENT_TIME), - INDEX idx_admin_event_realm GLOBAL ON (REALM_ID), - INDEX idx_admin_event_user GLOBAL ON (AUTH_USER_ID), - INDEX idx_admin_event_client GLOBAL ON (AUTH_CLIENT_ID), - INDEX idx_admin_event_operation GLOBAL ON (OPERATION_TYPE), PRIMARY KEY (ID) ); \ No newline at end of file From 495b1d17d2c56e92ff4ed086393fac908c1c762a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 15:25:42 +0300 Subject: [PATCH 107/120] chore: Add comments with foreign keys for info --- .../2025-12-17-21-29-create-realm-attributes-table.sql | 1 + ...2025-12-17-22-00-create-realm-required-credentials-table.sql | 1 + .../2025-12-20-00-20-create-realm_events_listeners-table.sql | 1 + .../2025-12-20-00-21-create-realm-default-groups-table.sql | 1 + .../2025-12-20-00-28-create-realm-enabled-event-types-table.sql | 1 + .../2025-12-20-00-30-create-realm-smtp-config-table.sql | 1 + .../2025-12-20-00-31-create-realm-supported-locales-table.sql | 1 + .../2025-12-20-00-34-create-default-client-scope-table.sql | 1 + .../2025-12-20-00-36-create-authentication-flow-table.sql | 1 + .../2025-12-20-00-38-create-authentication-execution-table.sql | 2 ++ .../2025-12-20-00-42-create-authenticator-config-table.sql | 1 + .../2025-12-20-00-44-create-required-action-provider-table.sql | 1 + .../ydb/changesets/2025-12-20-00-47-create-component-table.sql | 1 + .../2025-12-20-00-48-create-component-config-table.sql | 1 + .../ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql | 2 +- .../ydb/changesets/2026-02-01-16-04-composite-role-table.sql | 2 ++ .../2026-02-06-21-24-create-client-attributes-table.sql | 1 + .../2026-02-07-15-52-create-client-scope-role-mapping-table.sql | 1 - .../2026-02-07-16-02-create-user-role-mapping-table.sql | 1 - .../2026-02-07-16-19-create-client-auth-flow-bindings-table.sql | 2 -- .../2026-02-07-16-27-create-offline-user-session-table.sql | 1 - .../2026-02-07-16-37-create-user-group-membership-table.sql | 1 - .../changesets/2026-02-07-16-40-create-keycloak-group-table.sql | 2 -- .../ydb/changesets/2026-02-07-16-53-create-org-table.sql | 1 - .../changesets/2026-02-07-17-04-create-user-consent-table.sql | 1 - .../2026-02-07-17-05-create-user-consent-client-scope-table.sql | 1 - .../2026-02-07-17-08-create-federated-identity-table.sql | 1 - .../2026-02-07-17-09-create-fed-user-consent-table.sql | 1 - .../2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql | 2 -- .../2026-02-07-17-11-create-fed-user-credential-table.sql | 1 - .../2026-02-07-17-12-create-fed-user-group-membership-table.sql | 2 -- .../2026-02-07-17-12-create-fed-user-required-action-table.sql | 1 - .../2026-02-07-17-13-create-fed-user-role-mapping-table.sql | 2 -- .../changesets/2026-02-07-17-15-create-broker-link-table.sql | 1 - .../2026-02-07-17-16-create-fed-user-attribute-table.sql | 1 - .../2026-02-07-17-17-create-group-attribute-table.sql | 2 +- .../2026-02-07-17-18-create-group-role-mapping-table.sql | 1 - .../changesets/2026-05-20-01-create-associated-policy-table.sql | 2 ++ .../ydb/changesets/2026-05-20-02-create-scope-policy-table.sql | 2 ++ 39 files changed, 24 insertions(+), 26 deletions(-) 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 index e4ce543f..ac933656 100644 --- 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 @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS REALM_ATTRIBUTE 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 index cbdc40f3..efea8a69 100644 --- 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 @@ -6,5 +6,6 @@ create table IF NOT EXISTS REALM_REQUIRED_CREDENTIAL 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 index 467db2ce..3880b73e 100644 --- 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 @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS REALM_EVENTS_LISTENERS `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 index eb9c592a..e4010123 100644 --- 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 @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS `REALM_DEFAULT_GROUPS` `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 index d8a06a35..de7385ec 100644 --- 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 @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS `REALM_ENABLED_EVENT_TYPES` `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-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 index 1dc313c7..9da6db8b 100644 --- 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 @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS `REALM_SMTP_CONFIG` `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 index 60694555..899fdbf9 100644 --- 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 @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS `REALM_SUPPORTED_LOCALES` `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 index caed1def..16fc19de 100644 --- 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 @@ -6,5 +6,6 @@ CREATE TABLE IF NOT EXISTS `DEFAULT_CLIENT_SCOPE` 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 index f34deebe..b788e5ea 100644 --- 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 @@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS `AUTHENTICATION_FLOW` `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 index f77ba038..56ef1b79 100644 --- 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 @@ -13,5 +13,7 @@ CREATE TABLE IF NOT EXISTS `AUTHENTICATION_EXECUTION` 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 index 2e2f290e..acfe4879 100644 --- 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 @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS `AUTHENTICATOR_CONFIG` `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 index ce278295..cf5fa858 100644 --- 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 @@ -10,5 +10,6 @@ CREATE TABLE IF NOT EXISTS `REQUIRED_ACTION_PROVIDER` `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-47-create-component-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql index e73f2f9d..8bf697ca 100644 --- 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 @@ -10,5 +10,6 @@ CREATE TABLE IF NOT EXISTS COMPONENT 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 index b5117982..a391c65d 100644 --- 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 @@ -6,5 +6,6 @@ CREATE TABLE IF NOT EXISTS COMPONENT_CONFIG `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/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 index da00e0c0..fd757fa7 100644 --- 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 @@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS KEYCLOAK_ROLE INDEX idx_keycloak_role_client GLOBAL ON (`CLIENT`), --- foreign key to REALM 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 index 2b10487a..24294957 100644 --- 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 @@ -5,5 +5,7 @@ CREATE TABLE IF NOT EXISTS COMPOSITE_ROLE 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-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 index 4e3c0396..db936ced 100644 --- 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 @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS CLIENT_ATTRIBUTES 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-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 index dfd04c04..fabf479a 100644 --- 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 @@ -6,6 +6,5 @@ CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_ROLE_MAPPING INDEX idx_clscope_role GLOBAL ON (SCOPE_ID), INDEX idx_role_clscope GLOBAL ON (ROLE_ID), -- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID), --- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (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-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 index cf21289b..65485a75 100644 --- 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 @@ -5,6 +5,5 @@ CREATE TABLE IF NOT EXISTS USER_ROLE_MAPPING INDEX idx_user_role_mapping GLOBAL ON (USER_ID), -- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), --- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (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-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 index b891dfd8..e535262e 100644 --- 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 @@ -4,7 +4,5 @@ CREATE TABLE IF NOT EXISTS CLIENT_AUTH_FLOW_BINDINGS `FLOW_ID` Utf8, `BINDING_NAME` Utf8 NOT NULL, --- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID), --- FOREIGN KEY (FLOW_ID) REFERENCES AUTHENTICATION_FLOW (ID), 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-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 index 2c782e97..a3682ef6 100644 --- 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 @@ -13,6 +13,5 @@ CREATE TABLE IF NOT EXISTS OFFLINE_USER_SESSION 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), --- FOREIGN KEY (REALM_ID) REFERENCES 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 index 518341f6..3f0d0be0 100644 --- 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 @@ -6,6 +6,5 @@ CREATE TABLE IF NOT EXISTS USER_GROUP_MEMBERSHIP INDEX idx_user_group_mapping GLOBAL ON (USER_ID), -- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID), --- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (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 index 7c059b0a..59fcdcf2 100644 --- 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 @@ -8,7 +8,5 @@ CREATE TABLE IF NOT EXISTS KEYCLOAK_GROUP `DESCRIPTION` Utf8, -- CONSTRAINT `SIBLING_NAMES` GLOBAL UNIQUE ON (REALM_ID, PARENT_GROUP, NAME), --- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), --- FOREIGN KEY (PARENT_GROUP) 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-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 index 63cc6da8..70e97eb8 100644 --- 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 @@ -12,6 +12,5 @@ CREATE TABLE IF NOT EXISTS ORG -- 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), --- 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-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 index 649c16b8..57fa7fd8 100644 --- 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 @@ -12,6 +12,5 @@ CREATE TABLE IF NOT EXISTS USER_CONSENT -- 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), --- FOREIGN KEY (CLIENT_ID) REFERENCES 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-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 index 2000c865..8e415fb8 100644 --- 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 @@ -6,6 +6,5 @@ CREATE TABLE IF NOT EXISTS USER_CONSENT_CLIENT_SCOPE 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), --- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (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 index 5b7510bf..b7de43cf 100644 --- 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 @@ -10,6 +10,5 @@ CREATE TABLE IF NOT EXISTS FEDERATED_IDENTITY 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), --- FOREIGN KEY (REALM_ID) REFERENCES REALM (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 index a2663e47..6d9f2725 100644 --- 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 @@ -13,6 +13,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_CONSENT 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), --- 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-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 index 21cb32c8..b1463d1f 100644 --- 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 @@ -3,7 +3,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_CONSENT_CL_SCOPE `USER_CONSENT_ID` Utf8 NOT NULL, `SCOPE_ID` Utf8 NOT NULL, --- FOREIGN KEY (USER_CONSENT_ID) REFERENCES FED_USER_CONSENT (ID), --- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (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-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 index e10c8246..5e11a567 100644 --- 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 @@ -14,6 +14,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_CREDENTIAL INDEX idx_fu_credential GLOBAL ON (USER_ID, TYPE), INDEX idx_fu_credential_ru GLOBAL ON (REALM_ID, USER_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-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 index 3d6e7a71..66b65f47 100644 --- 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 @@ -7,7 +7,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_GROUP_MEMBERSHIP INDEX idx_fu_group_membership GLOBAL ON (USER_ID, GROUP_ID), INDEX idx_fu_group_membership_ru GLOBAL ON (REALM_ID, USER_ID), --- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), --- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (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 index d943ae68..719c676b 100644 --- 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 @@ -7,6 +7,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_REQUIRED_ACTION INDEX idx_fu_required_action GLOBAL ON (USER_ID, REQUIRED_ACTION), INDEX idx_fu_required_action_ru GLOBAL ON (REALM_ID, USER_ID), --- FOREIGN KEY (REALM_ID) REFERENCES REALM (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 index ef3debb0..d81d4a23 100644 --- 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 @@ -7,7 +7,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_ROLE_MAPPING INDEX idx_fu_role_mapping GLOBAL ON (USER_ID, ROLE_ID), INDEX idx_fu_role_mapping_ru GLOBAL ON (REALM_ID, USER_ID), --- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID), --- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (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-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 index 84aab4d2..4e90ef13 100644 --- 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 @@ -8,6 +8,5 @@ CREATE TABLE IF NOT EXISTS BROKER_LINK `TOKEN` Utf8, `USER_ID` Utf8 NOT NULL, --- FOREIGN KEY (REALM_ID) REFERENCES REALM (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-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 index ca836699..d98a215f 100644 --- 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 @@ -13,6 +13,5 @@ CREATE TABLE IF NOT EXISTS FED_USER_ATTRIBUTE 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), --- 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-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 index c42ba38e..f9a82f37 100644 --- 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 @@ -7,6 +7,6 @@ CREATE TABLE IF NOT EXISTS GROUP_ATTRIBUTE 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), +-- 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 index 63a6cddb..3c7e966e 100644 --- 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 @@ -5,6 +5,5 @@ CREATE TABLE IF NOT EXISTS GROUP_ROLE_MAPPING INDEX idx_group_role_mapp_group GLOBAL ON (GROUP_ID), -- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID), --- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (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-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 index 4abe3738..ae303452 100644 --- 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 @@ -4,5 +4,7 @@ CREATE TABLE IF NOT EXISTS ASSOCIATED_POLICY `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 index 4df5e31f..10b2d834 100644 --- 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 @@ -4,5 +4,7 @@ CREATE TABLE IF NOT EXISTS SCOPE_POLICY `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) ); From c40cf23fe7d89e2a1ebe39cd00ed32f62718d31e Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 15:29:01 +0300 Subject: [PATCH 108/120] chore: Update hibernate v7 dialect version. Use FOR UPDATE ignore hint --- .../keycloak/connection/YdbConnectionProviderFactoryImpl.kt | 3 +++ keycloak-ydb-extension/pom.xml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 index 9f62c040..954915a6 100644 --- 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 @@ -25,6 +25,7 @@ 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 @@ -238,6 +239,8 @@ class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInf 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 diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml index e2dd96fa..14a6409b 100644 --- a/keycloak-ydb-extension/pom.xml +++ b/keycloak-ydb-extension/pom.xml @@ -36,7 +36,7 @@ 1.1.1 7.0.2 - 0.9.1 + 0.9.2 3.4.1 1.5.16 From 9d694b8bc862c725c6ae97338baa8a5e3d934b7c Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 17:51:59 +0300 Subject: [PATCH 109/120] chore: Remove ignored tests after update of hibernate dialect version --- .../src/test/java/tech/ydb/keycloak/MigrationModelTest.java | 4 ---- 1 file changed, 4 deletions(-) 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 index ca9efe59..b05fc50e 100644 --- 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 @@ -2,7 +2,6 @@ import jakarta.persistence.EntityManager; import org.jboss.logging.Logger; -import org.junit.Ignore; import org.junit.Test; import org.keycloak.common.Version; import org.keycloak.connections.jpa.JpaConnectionProvider; @@ -46,7 +45,6 @@ public void cleanEnvironment(KeycloakSession s) { } @Test - @Ignore("After https://github.com/ydb-platform/ydb-java-dialects/pull/212") public void multipleEntities() { inComittedTransaction(1, (session , i) -> { String currentVersion = new ModelVersion(Version.VERSION).toString(); @@ -89,7 +87,6 @@ public void multipleEntities() { } @Test - @Ignore("After https://github.com/ydb-platform/ydb-java-dialects/pull/212") public void duplicates() { inComittedTransaction(1, (session, i) -> { String currentVersion = new ModelVersion(Version.VERSION).toString(); @@ -168,7 +165,6 @@ public void duplicates() { } @Test - @Ignore("After https://github.com/ydb-platform/ydb-java-dialects/pull/212") public void duplicatedUpdateTime() { inComittedTransaction(1, (session, i) -> { String currentVersion = new ModelVersion(Version.VERSION).toString(); From 25a4da3d2bcaadea72f78075492adb83f197848a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 16:02:06 +0300 Subject: [PATCH 110/120] test: Add tests for sessions and user --- .../resources/META-INF/queries-ydb.properties | 7 + .../keycloak/testsuite/KeycloakModelTest.java | 19 +- .../session/AuthenticationSessionTest.java | 236 +++++ .../OfflineSessionPersistenceTest.java | 397 ++++++++ .../UserSessionPersisterProviderTest.java | 874 ++++++++++++++++++ .../session/UserSessionProviderModelTest.java | 237 +++++ .../testsuite/user/UserModelTest.java | 82 ++ 7 files changed, 1850 insertions(+), 2 deletions(-) create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/AuthenticationSessionTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/OfflineSessionPersistenceTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionPersisterProviderTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/session/UserSessionProviderModelTest.java create mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/user/UserModelTest.java 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 index 37aa33bf..56b7dc08 100644 --- 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 @@ -24,6 +24,13 @@ findUserSessionsByExternalClientId[jpql]=SELECT sess FROM PersistentUserSessionE 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 \ 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 index 4246f8e2..01622f0f 100644 --- 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 @@ -442,7 +442,7 @@ protected void inRolledBackTransaction(T parameter, BiConsumer R inComittedTransaction(T parameter, BiFunction what) { - return inComittedTransaction(parameter, what, null); + return inComittedTransaction(parameter, what, null, null); } protected void inComittedTransaction(Consumer what) { @@ -453,11 +453,17 @@ protected void inComittedTransaction(Consumer what) { } protected R inComittedTransaction(Function what) { - return inComittedTransaction(1, (a, b) -> what.apply(a), null); + 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() { @@ -468,6 +474,7 @@ protected void commitImpl() { @Override protected void rollbackImpl() { + if (onRollback != null) onRollback.accept(session, parameter); } }); res.set(what.apply(session, parameter)); @@ -483,6 +490,13 @@ protected R withRealm(String realmId, BiFunction what) { + withRealm(realmId, (session, realm) -> { + what.accept(session, realm); + return null; + }); + } + protected boolean isUseSameKeycloakSessionFactoryForAllThreads() { return false; } @@ -499,6 +513,7 @@ protected void sleep(long timeMs) { 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); 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/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); + } +} From 88406f66943cfe58bd1b652d991bc748f2ed94dc Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 17:22:04 +0300 Subject: [PATCH 111/120] Revert "test: Add ClientScopeStorageTest" because it does not use YDB This reverts commit 9c4bb37316179d5e3aad25efd586f3ba0f83f849. --- keycloak-ydb-extension/test/pom.xml | 2 +- .../clientscope/ClientScopeStorageTest.java | 59 ------ .../HardcodedClientScopeStorageProvider.java | 179 ------------------ ...odedClientScopeStorageProviderFactory.java | 42 ---- .../testsuite/parameters/YdbFederation.java | 78 -------- ...entscope.ClientScopeStorageProviderFactory | 1 - 6 files changed, 1 insertion(+), 360 deletions(-) delete mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java delete mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java delete mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java delete mode 100644 keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java delete mode 100644 keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml index 7cc91fa9..b6ca97b6 100644 --- a/keycloak-ydb-extension/test/pom.xml +++ b/keycloak-ydb-extension/test/pom.xml @@ -147,7 +147,7 @@ -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED - Ydb,YdbFederation,Infinispan + 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/testsuite/clientscope/ClientScopeStorageTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java deleted file mode 100644 index 76ef538a..00000000 --- a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/ClientScopeStorageTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package tech.ydb.keycloak.testsuite.clientscope; - -import org.hamcrest.Matchers; -import org.junit.Assert; -import org.junit.Assume; -import org.junit.Test; -import org.keycloak.component.ComponentModel; -import org.keycloak.models.*; -import org.keycloak.storage.StorageId; -import org.keycloak.storage.clientscope.ClientScopeStorageProvider; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; -import tech.ydb.keycloak.testsuite.KeycloakModelTest; -import tech.ydb.keycloak.testsuite.RequireProvider; - -@RequireProvider(RealmProvider.class) -@RequireProvider(ClientScopeStorageProvider.class) -public class ClientScopeStorageTest extends KeycloakModelTest { - - private String realmId; - private String clientScopeFederationId; - - @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 testGetClientScopeById() { - getParameters(ClientScopeStorageProviderModel.class).forEach(fs -> inComittedTransaction(fs, (session, federatedStorage) -> { - Assume.assumeThat("Cannot handle more than 1 client scope federation provider", clientScopeFederationId, Matchers.nullValue()); - RealmModel realm = session.realms().getRealm(realmId); - federatedStorage.setParentId(realmId); - federatedStorage.setEnabled(true); - federatedStorage.getConfig().putSingle(HardcodedClientScopeStorageProviderFactory.SCOPE_NAME, HardcodedClientScopeStorageProviderFactory.SCOPE_NAME); - ComponentModel res = realm.addComponentModel(federatedStorage); - clientScopeFederationId = res.getId(); - log.infof("Added %s client scope federation provider: %s", federatedStorage.getName(), clientScopeFederationId); - return null; - })); - - inComittedTransaction(1, (session, i) -> { - final RealmModel realm = session.realms().getRealm(realmId); - StorageId storageId = new StorageId(clientScopeFederationId, "scope_name"); - ClientScopeModel hardcoded = session.clientScopes().getClientScopeById(realm, storageId.getId()); - Assert.assertNotNull(hardcoded); - return null; - }); - } -} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java deleted file mode 100644 index 93041bb8..00000000 --- a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProvider.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2021 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. - */ -package tech.ydb.keycloak.testsuite.clientscope; - -import org.keycloak.models.*; -import org.keycloak.storage.StorageId; -import org.keycloak.storage.clientscope.ClientScopeLookupProvider; -import org.keycloak.storage.clientscope.ClientScopeStorageProvider; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; - -import java.util.Collections; -import java.util.Map; -import java.util.stream.Stream; - - -public class HardcodedClientScopeStorageProvider implements ClientScopeStorageProvider, ClientScopeLookupProvider { - - private final ClientScopeStorageProviderModel component; - private final String clientScopeName; - - public HardcodedClientScopeStorageProvider(KeycloakSession session, ClientScopeStorageProviderModel component) { - this.component = component; - this.clientScopeName = component.getConfig().getFirst(HardcodedClientScopeStorageProviderFactory.SCOPE_NAME); - } - - @Override - public ClientScopeModel getClientScopeById(RealmModel realm, String id) { - StorageId storageId = new StorageId(id); - final String scopeName = storageId.getExternalId(); - if (this.clientScopeName.equals(scopeName)) return new HardcodedClientScopeAdapter(realm); - return null; - } - - @Override - public void close() { - } - - public class HardcodedClientScopeAdapter implements ClientScopeModel { - - private final RealmModel realm; - private StorageId storageId; - - public HardcodedClientScopeAdapter(RealmModel realm) { - this.realm = realm; - } - - @Override - public String getId() { - if (storageId == null) { - storageId = new StorageId(component.getId(), getName()); - } - return storageId.getId(); - } - - @Override - public String getName() { - return clientScopeName; - } - - @Override - public RealmModel getRealm() { - return realm; - } - - @Override - public void setName(String name) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public String getDescription() { - return "Federated client scope"; - } - - @Override - public void setDescription(String description) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public String getProtocol() { - return "openid-connect"; - } - - @Override - public void setProtocol(String protocol) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public void setAttribute(String name, String value) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public void removeAttribute(String name) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public String getAttribute(String name) { - return null; - } - - @Override - public Map getAttributes() { - return Collections.EMPTY_MAP; - } - - @Override - public Stream getProtocolMappersStream() { - return Stream.empty(); - } - - @Override - public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public void removeProtocolMapper(ProtocolMapperModel mapping) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public void updateProtocolMapper(ProtocolMapperModel mapping) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public ProtocolMapperModel getProtocolMapperById(String id) { - return null; - } - - @Override - public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) { - return null; - } - - @Override - public Stream getScopeMappingsStream() { - return Stream.empty(); - } - - @Override - public Stream getRealmScopeMappingsStream() { - return Stream.empty(); - } - - @Override - public void addScopeMapping(RoleModel role) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public void deleteScopeMapping(RoleModel role) { - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public boolean hasScope(RoleModel role) { - return false; - } - } -} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java deleted file mode 100644 index 957c5cc3..00000000 --- a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/clientscope/HardcodedClientScopeStorageProviderFactory.java +++ /dev/null @@ -1,42 +0,0 @@ -package tech.ydb.keycloak.testsuite.clientscope; - -import java.util.List; -import org.keycloak.component.ComponentModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.provider.ProviderConfigProperty; -import org.keycloak.provider.ProviderConfigurationBuilder; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; - -public class HardcodedClientScopeStorageProviderFactory implements ClientScopeStorageProviderFactory { - - public static final String PROVIDER_ID = "hardcoded-clientscope"; - public static final String SCOPE_NAME = "scope_name"; - protected static final List CONFIG_PROPERTIES; - - @Override - public HardcodedClientScopeStorageProvider create(KeycloakSession session, ComponentModel model) { - return new HardcodedClientScopeStorageProvider(session, new ClientScopeStorageProviderModel(model)); - } - - @Override - public String getId() { - return PROVIDER_ID; - } - - static { - CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() - .property().name(SCOPE_NAME) - .type(ProviderConfigProperty.STRING_TYPE) - .label("Hardcoded Scope Name") - .helpText("Only this scope name is available for lookup") - .defaultValue("hardcoded-clientscope") - .add() - .build(); - } - - @Override - public List getConfigProperties() { - return CONFIG_PROPERTIES; - } -} diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java deleted file mode 100644 index 2dc258f8..00000000 --- a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/parameters/YdbFederation.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2020 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. - */ -package tech.ydb.keycloak.testsuite.parameters; - -import org.keycloak.provider.ProviderFactory; -import org.keycloak.provider.Spi; -import org.keycloak.storage.UserStorageProviderSpi; -import org.keycloak.storage.federated.UserFederatedStorageProviderSpi; -import org.keycloak.storage.jpa.JpaUserFederatedStorageProviderFactory; -import com.google.common.collect.ImmutableSet; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Stream; -import org.keycloak.storage.clientscope.ClientScopeStorageProvider; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderModel; -import org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi; -import tech.ydb.keycloak.testsuite.Config; -import tech.ydb.keycloak.testsuite.KeycloakModelParameters; -import tech.ydb.keycloak.testsuite.clientscope.HardcodedClientScopeStorageProviderFactory; - -/** - * - * @author hmlnarik - */ -public class YdbFederation extends KeycloakModelParameters { - - private final AtomicInteger counter = new AtomicInteger(); - - static final Set> ALLOWED_SPIS = ImmutableSet.>builder() - .addAll(Ydb.ALLOWED_SPIS) - .add(UserStorageProviderSpi.class) - .add(UserFederatedStorageProviderSpi.class) - .add(ClientScopeStorageProviderSpi.class) - - .build(); - - static final Set> ALLOWED_FACTORIES = ImmutableSet.>builder() - .addAll(Ydb.ALLOWED_FACTORIES) - .add(JpaUserFederatedStorageProviderFactory.class) - .add(ClientScopeStorageProviderFactory.class) - .build(); - - public YdbFederation() { - super(ALLOWED_SPIS, ALLOWED_FACTORIES); - } - - @Override - public Stream getParameters(Class clazz) { - if (ClientScopeStorageProviderModel.class.isAssignableFrom(clazz)) { - ClientScopeStorageProviderModel federatedStorage = new ClientScopeStorageProviderModel(); - federatedStorage.setName(HardcodedClientScopeStorageProviderFactory.PROVIDER_ID + ":" + counter.getAndIncrement()); - federatedStorage.setProviderId(HardcodedClientScopeStorageProviderFactory.PROVIDER_ID); - federatedStorage.setProviderType(ClientScopeStorageProvider.class.getName()); - return Stream.of((T) federatedStorage); - } else { - return super.getParameters(clazz); - } - } - - @Override - public void updateConfig(Config cf) { - } -} diff --git a/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory b/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory deleted file mode 100644 index 5134756f..00000000 --- a/keycloak-ydb-extension/test/src/test/resources/META-INF/services/org.keycloak.storage.clientscope.ClientScopeStorageProviderFactory +++ /dev/null @@ -1 +0,0 @@ -tech.ydb.keycloak.testsuite.clientscope.HardcodedClientScopeStorageProviderFactory \ No newline at end of file From 17901fd0f1579b8ce90da2a7bc1e5d60fff31b5b Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Wed, 20 May 2026 18:02:17 +0300 Subject: [PATCH 112/120] chore: Remove ignored tests after update of hibernate dialect version --- .../testsuite/group/GroupModelTest.java | 2 -- .../testsuite/role/RoleModelTest.java | 19 ------------------- 2 files changed, 21 deletions(-) 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 index d56ff81d..a44bd528 100644 --- 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 @@ -1,6 +1,5 @@ package tech.ydb.keycloak.testsuite.group; -import org.junit.Ignore; import org.junit.Test; import org.keycloak.models.Constants; import org.keycloak.models.GroupModel; @@ -152,7 +151,6 @@ public void testConflictingNames() { } @Test - @Ignore("Implement YdbRealmProvider.removeGroup or use ignore FOR UPDATE in hibernate-dialect") public void testGroupByNameCacheInvalidation() { String subGroupId1 = withRealm(realmId, (session, realm) -> { GroupModel group = session.groups().createGroup(realm, "parent-1"); 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 index 32a2fdbf..427fd468 100644 --- 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 @@ -1,8 +1,6 @@ package tech.ydb.keycloak.testsuite.role; import org.hamcrest.Matcher; -import org.junit.Assume; -import org.junit.Ignore; import org.junit.Test; import org.keycloak.models.*; import tech.ydb.keycloak.testsuite.KeycloakModelTest; @@ -132,13 +130,11 @@ public void testCompositeRoles() { } @Test - @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") public void testRolesWithIdsSearchQueries() { testRolesWithIdsSearchQueries(this::getResult); } @Test - @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") public void testCompositeRolesSearchQueries() { testRolesWithIdsSearchQueries(this::getModelResult); } @@ -197,13 +193,11 @@ public void testRolesWithIdsPaginationQueries(GetResult resultProvider) { } @Test - @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") public void testRolesWithIdsPaginationSearchQueries() { testRolesWithIdsPaginationSearchQueries(this::getResult); } @Test - @Ignore("Unignore after https://github.com/ydb-platform/ydb-java-dialects/pull/207") public void testCompositeRolesPaginationSearchQueries() { testRolesWithIdsPaginationSearchQueries(this::getModelResult); } @@ -286,19 +280,6 @@ public void testCompositeRolesUpdateOnChildRoleRemoval() { }); } - @Test - public void getRolePathTraversal() { - // Only perform this test if realm role ID = role.name and client role ID = client.id + ":" + role.name - Assume.assumeThat(mainRoleId, is(MAIN_ROLE_NAME)); - Assume.assumeTrue(rolesSubset.stream().anyMatch((CLIENT_NAME + ":" + ROLE_PREFIX + "10")::equals)); - - withRealm(realmId, (session, realm) -> { - RoleModel role = session.roles().getRoleById(realm, (CLIENT_NAME + ":" + ROLE_PREFIX + "10") + "/../../" + MAIN_ROLE_NAME); - assertThat(role, nullValue()); - return null; - }); - } - @Test public void getRoleByNameFromTheDatabaseAndTheCache() { String roleName = "role-" + new Random().nextInt(); From 58068d5ad8b1ffe7f88734d3a0c875da3deb8d34 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 00:58:28 +0300 Subject: [PATCH 113/120] fix: Update YDB healthcheck, use service_healthy in depends_on --- keycloak-ydb-extension/docker/docker-compose.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml index f05d19c2..90ff5f6f 100644 --- a/keycloak-ydb-extension/docker/docker-compose.yml +++ b/keycloak-ydb-extension/docker/docker-compose.yml @@ -30,7 +30,7 @@ services: - MON_PORT=8765 - YDB_KAFKA_PROXY_PORT=9092 healthcheck: - test: [ "CMD", "ydb", "--endpoint", "grpc://localhost:2136", "--database", "/local", "scheme", "ls" ] + test: [ "CMD-SHELL", "/bin/sh /health_check" ] interval: 10s timeout: 5s retries: 5 @@ -51,7 +51,7 @@ services: - keycloak depends_on: ydb: - condition: service_started + condition: service_healthy retry-proxy: build: @@ -67,4 +67,6 @@ services: networks: - keycloak depends_on: - - ydb-keycloak + ydb-keycloak: + condition: service_started + restart: on-failure From 4a60ae8c71fae67059a812e0d4fa9baca14481fc Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 00:58:55 +0300 Subject: [PATCH 114/120] chore: Update comment in keycloak-remote-ydb.conf --- keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf index 17ab5a4a..13c5fa3b 100644 --- a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf +++ b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf @@ -2,8 +2,8 @@ # Mount this as /opt/keycloak/conf/keycloak.conf # --- YDB / JPA (this extension) --- -# Override YDB_JDBC_URL env var to point to your remote YDB instance, e.g.: -# grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/b1g.../etn... +# 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 From 6ec1f483683275325a02432994c017645eec26b7 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 01:02:21 +0300 Subject: [PATCH 115/120] fix: Remove redundant dependency --- keycloak-ydb-extension/core/pom.xml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/keycloak-ydb-extension/core/pom.xml b/keycloak-ydb-extension/core/pom.xml index f3f91aeb..200466fb 100644 --- a/keycloak-ydb-extension/core/pom.xml +++ b/keycloak-ydb-extension/core/pom.xml @@ -128,13 +128,6 @@ pom import - - org.junit - junit-bom - 6.0.1 - pom - import - From 064f83cdcf5d6d46341d5c8acfe10fb1c092ec9f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 01:03:18 +0300 Subject: [PATCH 116/120] fix: Correct log message --- .../tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 55fa2c93..6eabaea4 100644 --- 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 @@ -18,7 +18,7 @@ class YdbDBLockProviderFactory : DBLockProviderFactory { override fun init(config: Config.Scope) { this.lockWaitTimeoutMillis = Time.toMillis(config.getLong("lockWaitTimeout", 900)) - logger.debug("Liquibase lock provider configured with lockWaitTime: $lockWaitTimeoutMillis seconds") + logger.debug("Liquibase lock provider configured with lockWaitTimeout: ${lockWaitTimeoutMillis}ms") } override fun create(session: KeycloakSession): YdbDBLockProvider { From 998ede9fa84c1494caf8ba025f1ecca0fd031a8a Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 17:04:27 +0300 Subject: [PATCH 117/120] fix: Use `origin.scheme` instead of hardcoded http --- keycloak-ydb-extension/retry-proxy/pom.xml | 5 ++++ .../proxy/controller/ProxyController.kt | 17 ++++++++---- .../ydb/keycloak/proxy/plugins/Routing.kt | 3 +++ .../proxy/controller/ProxyControllerTest.kt | 26 ++++++++++++++++--- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/keycloak-ydb-extension/retry-proxy/pom.xml b/keycloak-ydb-extension/retry-proxy/pom.xml index d0fe3bc3..8b695e29 100644 --- a/keycloak-ydb-extension/retry-proxy/pom.xml +++ b/keycloak-ydb-extension/retry-proxy/pom.xml @@ -115,6 +115,11 @@ ktor-server-netty-jvm ${ktor.version} + + io.ktor + ktor-server-forwarded-header-jvm + ${ktor.version} + 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 index 53b5a6de..46e707a6 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -20,8 +21,8 @@ class ProxyController( val body = call.receive() val contentType = call.request.contentType() val host = call.request.headers["Host"] ?: call.request.host() - val remoteHost = call.request.local.remoteHost - val scheme = call.request.local.scheme + val remoteHost = call.request.origin.remoteHost + val scheme = call.request.origin.scheme val result = proxyService.proxyRequest( method = method, @@ -45,11 +46,12 @@ class ProxyController( 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)) + response.header(name, rewriteInternalUrl(name, value, originalHost, scheme)) } } } @@ -57,9 +59,14 @@ class ProxyController( respondBytes(result.body, result.contentType, result.status) } - private fun rewriteInternalUrl(name: String, value: String, originalHost: String): String { + private fun rewriteInternalUrl( + name: String, + value: String, + originalHost: String, + scheme: String, + ): String { if (isHeader(name, HttpHeaders.Location)) { - return value.replace(config.targetUrl, "http://$originalHost") + 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 index 04ea9e81..6b7571cb 100644 --- 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 @@ -1,10 +1,13 @@ 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 { 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 index 49529930..c8e3663e 100644 --- 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 @@ -108,10 +108,30 @@ class ProxyControllerTest { status = HttpStatusCode.Found, ) - createClient { followRedirects = false }.get("/login").let { + createClient { followRedirects = false }.get("/login") { + header(HttpHeaders.Host, "localhost:9090") + }.let { assertEquals(HttpStatusCode.Found, it.status) - assertTrue(it.headers[HttpHeaders.Location]!!.contains("/realms/master")) - assertFalse(it.headers[HttpHeaders.Location]!!.contains("backend:8080")) + 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]) } } From 13b307f4af96aa8a1b7d78eca97eed0f01283af1 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 17:36:35 +0300 Subject: [PATCH 118/120] refactor: Move retryable exception detecting to another file --- .../keycloak/proxy/service/ProxyService.kt | 5 +- .../keycloak/proxy/utils/YdbRetryableBody.kt | 10 ++++ .../proxy/utils/YdbRetryableBodyTest.kt | 55 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt create mode 100644 keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt 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 index 4de789d9..0024a45d 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -46,9 +47,7 @@ class ProxyService( val responseBody = response.readRawBytes() - val isRetryable = response.status.value == 503 && String(responseBody).contains("ydb_retryable") - - if (isRetryable) { + if (YdbRetryableBody.isRetryable503(response.status, responseBody)) { if (retryWithBackoff(attempt, method, path)) continue } else { return Success(responseBody, response.headers, response.contentType(), response.status) 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/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()))) + } +} From b589f57a0a4d25bdf2c792d208a3491d4b626339 Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Thu, 21 May 2026 17:57:54 +0300 Subject: [PATCH 119/120] ci: add keycloak-ydb-extensions GitHub Actions workflow --- .../workflows/ci-keycloak-ydb-extension.yaml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/ci-keycloak-ydb-extension.yaml 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 From 68589746f085edc30d57c8f5569a5c59e3c2195f Mon Sep 17 00:00:00 2001 From: Ainur Mukhtarov Date: Fri, 22 May 2026 10:00:35 +0300 Subject: [PATCH 120/120] fix: Update to ASYNC index in CLIENT --- .../ydb/changesets/2025-12-20-01-00-create-client-table.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 132067ac..b2f7cb03 100644 --- 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 @@ -27,6 +27,6 @@ CREATE TABLE IF NOT EXISTS CLIENT `DIRECT_ACCESS_GRANTS_ENABLED` Bool DEFAULT false, `ALWAYS_DISPLAY_IN_CONSOLE` Bool DEFAULT false, - INDEX idx_client_id GLOBAL ON (`CLIENT_ID`), + INDEX idx_client_id GLOBAL ASYNC ON (`CLIENT_ID`), PRIMARY KEY (`ID`) );