diff --git a/.github/workflows/ci-keycloak-ydb-extension.yaml b/.github/workflows/ci-keycloak-ydb-extension.yaml
new file mode 100644
index 00000000..a309f863
--- /dev/null
+++ b/.github/workflows/ci-keycloak-ydb-extension.yaml
@@ -0,0 +1,39 @@
+name: Keycloak YDB Extension CI with Maven
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'keycloak-ydb-extension/**'
+ - '.github/workflows/ci-keycloak-ydb-extension.yaml'
+ pull_request:
+ paths:
+ - 'keycloak-ydb-extension/**'
+ - '.github/workflows/ci-keycloak-ydb-extension.yaml'
+
+env:
+ MAVEN_ARGS: --batch-mode --update-snapshots -Dstyle.color=always
+
+jobs:
+ build:
+ name: Keycloak YDB Extension
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: maven
+
+ - name: Download dependencies
+ working-directory: ./keycloak-ydb-extension
+ run: mvn $MAVEN_ARGS dependency:go-offline
+
+ - name: Build & test (unit + Keycloak model tests with YDB)
+ working-directory: ./keycloak-ydb-extension
+ run: mvn $MAVEN_ARGS verify
diff --git a/keycloak-ydb-extension/.gitignore b/keycloak-ydb-extension/.gitignore
new file mode 100644
index 00000000..f80243a7
--- /dev/null
+++ b/keycloak-ydb-extension/.gitignore
@@ -0,0 +1,4 @@
+dependency-reduced-pom.xml
+
+# jar files of keycloak-ydb-extension
+docker/providers
diff --git a/keycloak-ydb-extension/README.md b/keycloak-ydb-extension/README.md
new file mode 100644
index 00000000..fdb3c925
--- /dev/null
+++ b/keycloak-ydb-extension/README.md
@@ -0,0 +1,51 @@
+# Keycloak YDB extension
+
+## Overview
+
+Keycloak extension to use [YDB](https://ydb.tech/) as the main database. Keycloak does not support YDB as a built-in database (see [Configuring the database](https://www.keycloak.org/server/db)); this extension provides the JDBC driver and Hibernate dialect.
+
+## Configuration
+
+When using YDB you must avoid giving the YDB URL to Keycloak’s default datasource (it would fail with “Driver does not support the provided URL”). Use:
+
+| Variable | Value | Purpose |
+|----------|--------|---------|
+| `KC_DB` | `dev-file` | Built-in datasource uses dev-file; it never sees the YDB URL. |
+| `KC_SPI_CONNECTIONS_JPA_DEFAULT_YDB_JDBC_URL` | `jdbc:ydb:grpc://host:2136/database` | JDBC URL used by this extension (required). |
+| `KC_SPI_CONNECTIONS_JPA_QUARKUS_ENABLED` | `false` | Disables the default Quarkus JPA provider so H2 is not used. |
+| `KC_SPI_CONNECTIONS_LIQUIBASE_QUARKUS_ENABLED` | `false` | Disables the default Quarkus Liquibase provider (migrations are run by this extension). |
+
+The extension is enabled when its JAR is in the `providers` directory and the YDB JDBC URL is configured. No separate “enable” flag is required.
+
+### Connection Pool (HikariCP)
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `KC_SPI_CONNECTIONS_JPA_YDB_POOL_SIZE` | `50` | Maximum pool size |
+| `KC_SPI_CONNECTIONS_JPA_YDB_MIN_IDLE` | `10` | Minimum idle connections |
+| `KC_SPI_CONNECTIONS_JPA_YDB_CONNECTION_TIMEOUT` | `30000` | Connection timeout (ms) |
+| `KC_SPI_CONNECTIONS_JPA_YDB_IDLE_TIMEOUT` | `600000` | Idle connection timeout (ms) |
+| `KC_SPI_CONNECTIONS_JPA_YDB_MAX_LIFETIME` | `1800000` | Max connection lifetime (ms) |
+
+Or in `keycloak.conf`:
+
+```properties
+spi-connections-jpa-ydb-pool-size=50
+spi-connections-jpa-ydb-min-idle=10
+```
+
+## Getting started
+
+1. Build and package the extension, then put the JAR in Keycloak’s `providers` directory (or use the Docker setup below).
+2. Set the environment variables above and start Keycloak.
+
+### Local development with Docker
+
+From the project root:
+
+```bash
+./run-keycloak-with-ydb.sh
+```
+
+This builds the extension, copies the JAR to `docker/providers/`, and starts Keycloak + YDB via `docker/docker-compose.yml`.
+All Keycloak options (YDB URL, admin user, etc.) are set in `docker/conf/keycloak.conf`; override with environment variables if needed.
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/pom.xml b/keycloak-ydb-extension/core/pom.xml
new file mode 100644
index 00000000..200466fb
--- /dev/null
+++ b/keycloak-ydb-extension/core/pom.xml
@@ -0,0 +1,212 @@
+
+
+ 4.0.0
+
+
+ tech.ydb
+ keycloak-ydb-extension-parent
+ 1.0-SNAPSHOT
+ ../pom.xml
+
+
+ jar
+ keycloak-ydb-extension (core)
+ keycloak-ydb-extension
+ 1.0-SNAPSHOT
+
+
+ src/main/kotlin
+ src/test/kotlin
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+
+
+
+ default-compile
+ none
+
+
+ default-testCompile
+ none
+
+
+ java-compile
+
+ compile
+
+ compile
+
+
+ java-test-compile
+
+ testCompile
+
+ test-compile
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+ ${kotlin.version}
+
+
+ compile
+
+ compile
+
+
+
+ ${project.basedir}/src/main/kotlin
+ ${project.basedir}/src/main/java
+
+
+
+
+ test-compile
+
+ test-compile
+
+
+
+ ${project.basedir}/src/test/kotlin
+ ${project.basedir}/src/test/java
+
+
+
+
+
+
+
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+
+
+ org.jetbrains.kotlin:kotlin-stdlib
+ org.jetbrains.kotlin:kotlin-reflect
+ org.jetbrains.kotlin:*
+
+ tech.ydb.jdbc:*
+ tech.ydb:*
+ tech.ydb.dialects:*
+ com.zaxxer:HikariCP
+ io.r2dbc:*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ org.keycloak
+ keycloak-parent
+ ${keycloak.version}
+ pom
+ import
+
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+ ${kotlin.version}
+
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ ${kotlin.version}
+
+
+
+
+ org.keycloak
+ keycloak-server-spi
+ provided
+
+
+ org.keycloak
+ keycloak-server-spi-private
+ provided
+
+
+ org.keycloak
+ keycloak-model-jpa
+ provided
+
+
+
+
+ tech.ydb.jdbc
+ ydb-jdbc-driver
+ ${ydb-jdbc-driver.version}
+
+
+
+ tech.ydb.dialects
+ liquibase-ydb-dialect
+ 1.1.1
+
+
+
+ tech.ydb.dialects
+ hibernate-ydb-dialect-v7
+ ${hibernate-v7.ydb.dialect.version}
+
+
+
+ com.zaxxer
+ HikariCP
+ ${hikaricp.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ tech.ydb.test
+ ydb-junit5-support
+ 2.3.27
+ test
+
+
+ org.mockito.kotlin
+ mockito-kotlin
+ 6.1.0
+ test
+
+
+ org.jboss.resteasy
+ resteasy-core
+ test
+
+
+
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt
new file mode 100644
index 00000000..68f72dcb
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientProviderFactory.kt
@@ -0,0 +1,71 @@
+package tech.ydb.keycloak.client
+
+import org.keycloak.Config
+import org.keycloak.authorization.fgap.AdminPermissionsSchema
+import org.keycloak.common.Profile
+import org.keycloak.connections.jpa.JpaConnectionProvider
+import org.keycloak.models.ClientProvider
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.KeycloakSessionFactory
+import org.keycloak.models.RealmModel.RealmAttributeUpdateEvent
+import org.keycloak.models.jpa.JpaClientProviderFactory
+import org.keycloak.models.jpa.entities.RealmAttributes
+import org.keycloak.protocol.saml.SamlConfigAttributes
+import org.keycloak.provider.ProviderEvent
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY
+import tech.ydb.keycloak.realm.YdbRealmProvider
+
+class YdbClientProviderFactory : JpaClientProviderFactory() {
+
+ private lateinit var clientSearchableAttributes: Set
+
+ override fun init(config: Config.Scope) {
+ var searchableAttrsArr = config.getArray("searchableAttributes")?.toList()
+ if (searchableAttrsArr == null) {
+ val s = System.getProperty("keycloak.client.searchableAttributes")
+ searchableAttrsArr = s?.split("\\s*,\\s*".toRegex())
+ }
+ val s = HashSet(REQUIRED_SEARCHABLE_ATTRIBUTES)
+ if (searchableAttrsArr != null) {
+ s.addAll(searchableAttrsArr)
+ }
+ clientSearchableAttributes = s.toSet()
+ }
+
+ override fun postInit(factory: KeycloakSessionFactory) {
+ if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) {
+ factory.register { event: ProviderEvent? ->
+ if (event is RealmAttributeUpdateEvent) {
+ if (event.attributeName == RealmAttributes.ADMIN_PERMISSIONS_ENABLED && event.attributeValue.toBoolean()) {
+ val keycloakSession = event.keycloakSession
+ val realm = event.realm
+ AdminPermissionsSchema.SCHEMA.init(keycloakSession, realm)
+ }
+ }
+ }
+ }
+ }
+
+ override fun create(session: KeycloakSession): ClientProvider {
+ val em = session.getProvider(JpaConnectionProvider::class.java).entityManager
+ return YdbRealmProvider(session, em, clientSearchableAttributes, null)
+ }
+
+ override fun close() {
+ // no operations
+ }
+
+ override fun order(): Int = PROVIDER_PRIORITY
+
+ override fun getId(): String = PROVIDER_ID
+
+ private companion object{
+ private val REQUIRED_SEARCHABLE_ATTRIBUTES = listOf(
+ "saml_idp_initiated_sso_url_name",
+ SamlConfigAttributes.SAML_ARTIFACT_BINDING_IDENTIFIER,
+ "jwt.credential.issuer",
+ "jwt.credential.sub"
+ )
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt
new file mode 100644
index 00000000..ed2b416e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/client/YdbClientScopeProviderFactory.kt
@@ -0,0 +1,20 @@
+package tech.ydb.keycloak.client
+
+import org.keycloak.connections.jpa.JpaConnectionProvider
+import org.keycloak.models.ClientScopeProvider
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.jpa.JpaClientScopeProviderFactory
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY
+import tech.ydb.keycloak.realm.YdbRealmProvider
+
+class YdbClientScopeProviderFactory : JpaClientScopeProviderFactory() {
+ override fun create(session: KeycloakSession): ClientScopeProvider {
+ val em = session.getProvider(JpaConnectionProvider::class.java).entityManager
+ return YdbRealmProvider(session, em, null, null)
+ }
+
+ override fun order(): Int = PROVIDER_PRIORITY
+
+ override fun getId(): String = PROVIDER_ID
+}
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt
new file mode 100644
index 00000000..725b63f6
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/config/ProviderConfig.kt
@@ -0,0 +1,6 @@
+package tech.ydb.keycloak.config
+
+object ProviderConfig {
+ const val PROVIDER_PRIORITY = Int.MAX_VALUE
+ const val PROVIDER_ID = "ydb"
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt
new file mode 100644
index 00000000..954915a6
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbConnectionProviderFactoryImpl.kt
@@ -0,0 +1,347 @@
+package tech.ydb.keycloak.connection
+
+import com.zaxxer.hikari.HikariConfig
+import com.zaxxer.hikari.HikariDataSource
+import jakarta.persistence.EntityManagerFactory
+import jakarta.persistence.SynchronizationType.SYNCHRONIZED
+import liquibase.GlobalConfiguration
+import org.hibernate.cfg.AvailableSettings
+import org.jboss.logging.Logger
+import org.keycloak.Config
+import org.keycloak.ServerStartupError
+import org.keycloak.connections.jpa.DefaultJpaConnectionProvider
+import org.keycloak.connections.jpa.JpaConnectionProvider
+import org.keycloak.connections.jpa.JpaConnectionProviderFactory
+import org.keycloak.connections.jpa.support.EntityManagerProxy
+import org.keycloak.connections.jpa.updater.JpaUpdaterProvider
+import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.EMPTY
+import org.keycloak.connections.jpa.updater.JpaUpdaterProvider.Status.VALID
+import org.keycloak.connections.jpa.util.JpaUtils
+import org.keycloak.migration.MigrationModelManager
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.KeycloakSessionFactory
+import org.keycloak.models.dblock.DBLockManager
+import org.keycloak.models.dblock.DBLockProvider
+import org.keycloak.models.utils.KeycloakModelUtils
+import org.keycloak.provider.ServerInfoAwareProviderFactory
+import tech.ydb.hibernate.dialect.YdbDialect
+import tech.ydb.hibernate.dialect.YdbSettings
+import tech.ydb.jdbc.YdbDriver
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY
+import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl.Companion.MigrationStrategy.*
+import java.io.File
+import java.sql.Connection
+import java.sql.DriverManager
+import java.util.*
+import kotlin.properties.Delegates
+
+class YdbConnectionProviderFactoryImpl : JpaConnectionProviderFactory, ServerInfoAwareProviderFactory {
+
+ private val logger: Logger = Logger.getLogger(YdbConnectionProviderFactoryImpl::class.java)
+
+ private lateinit var config: Config.Scope
+
+ private var jtaEnabled by Delegates.notNull()
+ private lateinit var jdbcUrl: String
+
+ @Volatile
+ private lateinit var entityManagerFactory: EntityManagerFactory
+
+ private lateinit var dataSource: HikariDataSource
+
+ override fun create(session: KeycloakSession): JpaConnectionProvider {
+ val emf = getOrCreateEntityManagerFactory(session)
+
+ val em = if (!jtaEnabled) {
+ logger.trace("enlisting EntityManager in JpaKeycloakTransaction")
+ emf.createEntityManager()
+ } else {
+ emf.createEntityManager(SYNCHRONIZED)
+ }
+
+ val keycloakEm = EntityManagerProxy.create(session, em, true)
+ val ydbEm = YdbEntityManagerProxy.create(keycloakEm)
+
+ if (!jtaEnabled) {
+ session.transactionManager.enlist(YdbJpaKeycloakTransaction(ydbEm))
+ }
+ return DefaultJpaConnectionProvider(ydbEm)
+ }
+
+ override fun init(scope: Config.Scope) {
+ config = scope
+ jdbcUrl = requireNotNull(config["jdbcUrl"]) {
+ "YDB JDBC URL is required"
+ }
+ }
+
+ override fun postInit(factory: KeycloakSessionFactory) {
+ checkJtaEnabled(factory)
+
+ val schema = getSchema()
+ connection.use { connection ->
+ factory.create().use { session ->
+ createOrUpdateSchema(schema, connection, session)
+ }
+ }
+ factory.create().use { session -> getOrCreateEntityManagerFactory(session) }
+
+ KeycloakModelUtils.runJobInTransaction(factory) { session -> migrateModel(session) }
+ }
+
+ private fun createOrUpdateSchema(
+ schema: String?,
+ connection: Connection,
+ session: KeycloakSession
+ ) {
+ val strategy: MigrationStrategy = getMigrationStrategy()
+ val initializeEmpty = config.getBoolean("initializeEmpty", true)
+ val databaseUpdateFile: File = getDatabaseUpdateFile()
+
+ // actually it is QuarkusJpaUpdaterProvider and it works
+ val updater = session.getProvider(JpaUpdaterProvider::class.java)
+
+ val status = updater.validate(connection, schema)
+
+ if (status == VALID) {
+ logger.debug("Database is up-to-date")
+ } else if (status == EMPTY) {
+ if (initializeEmpty) {
+ update(connection, schema, session, updater)
+ } else {
+ when (strategy) {
+ UPDATE -> update(connection, schema, session, updater)
+ MANUAL -> {
+ export(connection, schema, databaseUpdateFile, session, updater)
+ throw ServerStartupError(
+ "Database not initialized, please initialize database with " + databaseUpdateFile.getAbsolutePath(),
+ false
+ )
+ }
+
+ VALIDATE -> throw ServerStartupError(
+ "Database not initialized, please enable database initialization",
+ false
+ )
+ }
+ }
+ } else {
+ when (strategy) {
+ UPDATE -> update(connection, schema, session, updater)
+ MANUAL -> {
+ export(connection, schema, databaseUpdateFile, session, updater)
+ throw ServerStartupError(
+ "Database not up-to-date, please migrate database with " + databaseUpdateFile.getAbsolutePath(),
+ false
+ )
+ }
+
+ VALIDATE -> throw ServerStartupError(
+ "Database not up-to-date, please enable database migration",
+ false
+ )
+ }
+ }
+ }
+
+ override fun close() {
+ if (::entityManagerFactory.isInitialized) {
+ entityManagerFactory.close()
+ }
+ if (::dataSource.isInitialized) {
+ dataSource.close()
+ }
+ }
+
+ override fun getId(): String = PROVIDER_ID
+
+ override fun order(): Int = PROVIDER_PRIORITY
+
+ override fun getConnection(): Connection {
+ try {
+ val driver = YdbDriver::class.java.name
+ Class.forName(driver)
+ return DriverManager.getConnection(jdbcUrl)
+ } catch (e: Exception) {
+ throw RuntimeException("Failed to connect to database", e)
+ }
+ }
+
+ override fun getSchema(): String? {
+ val schema = config.get("schema")?.takeIf { it.isNotBlank() }
+ if (schema?.contains("-") == true
+ && !System.getProperty(GlobalConfiguration.PRESERVE_SCHEMA_CASE.key).toBoolean()
+ ) {
+ System.setProperty(GlobalConfiguration.PRESERVE_SCHEMA_CASE.key, "true")
+ logger.warnf(
+ "The passed schema '%s' contains a dash. Setting liquibase config option PRESERVE_SCHEMA_CASE to true. See https://github.com/keycloak/keycloak/issues/20870 for more information.",
+ schema
+ )
+ }
+ return schema
+ }
+
+ // TODO: can be added more info for info in admin console
+ override fun getOperationalInfo(): Map = mapOf(
+ "YDB" to "enabled",
+ )
+
+
+ private fun checkJtaEnabled(factory: KeycloakSessionFactory) {
+ // for now, we do not need jta
+ // we will use resource local transaction
+ jtaEnabled = false
+ }
+
+ private fun getOrCreateEntityManagerFactory(session: KeycloakSession): EntityManagerFactory {
+ if (::entityManagerFactory.isInitialized) {
+ return entityManagerFactory
+ }
+ synchronized(this) {
+ if (::entityManagerFactory.isInitialized) {
+ return entityManagerFactory
+ }
+
+ val properties = buildPropertiesFromScope()
+
+ entityManagerFactory = JpaUtils.createEntityManagerFactory(session, PERSISTENCE_UNIT_NAME, properties, jtaEnabled)
+ addSpecificNamedQueries()
+ logger.info("YDB EntityManagerFactory created via JpaUtils")
+ return entityManagerFactory
+ }
+ }
+
+ /**
+ * Load YDB-specific named query overrides from META-INF/queries-ydb.properties.
+ * Follows the same pattern as DefaultJpaConnectionProviderFactory.addSpecificNamedQueries().
+ */
+ private fun addSpecificNamedQueries() = entityManagerFactory.createEntityManager().use { em ->
+ JpaUtils.loadSpecificNamedQueries(DB_KIND).forEach { (queryName, querySql) ->
+ JpaUtils.configureNamedQuery(queryName.toString(), querySql.toString(), em)
+ }
+ }
+
+ private fun buildPropertiesFromScope(): MutableMap {
+ val properties = mutableMapOf()
+
+ val hikariConfig = HikariConfig().apply {
+ jdbcUrl = this@YdbConnectionProviderFactoryImpl.jdbcUrl
+ driverClassName = YdbDriver::class.java.name
+ maximumPoolSize = config.getInt("poolSize", 50)
+ minimumIdle = config.getInt("minIdle", 10)
+ connectionTimeout = config.getLong("connectionTimeout", 30000)
+ idleTimeout = config.getLong("idleTimeout", 600000)
+ maxLifetime = config.getLong("maxLifetime", 1800000)
+ }
+ dataSource = HikariDataSource(hikariConfig)
+ logger.info("HikariCP pool created: maxSize=${hikariConfig.maximumPoolSize}, minIdle=${hikariConfig.minimumIdle}")
+
+ properties[AvailableSettings.JAKARTA_NON_JTA_DATASOURCE] = dataSource
+
+ properties[YdbSettings.IGNORE_LOCK_HINTS] = true
+
+ getSchema()?.let { properties[JpaUtils.HIBERNATE_DEFAULT_SCHEMA] = it }
+
+ properties["hibernate.dialect"] = YdbDialect::class.java.name
+
+ properties["hibernate.show_sql"] = config.getBoolean("showSql", false)
+ properties["hibernate.format_sql"] = config.getBoolean("formatSql", true)
+
+ val globalStatsInterval = config.getInt("globalStatsInterval", -1)
+ if (globalStatsInterval != -1) {
+ properties.put("hibernate.generate_statistics", true)
+ }
+
+ val classLoaders = ArrayList()
+
+ if (properties.containsKey(AvailableSettings.CLASSLOADERS)) {
+ classLoaders.addAll(properties.get(AvailableSettings.CLASSLOADERS) as Collection)
+ }
+ classLoaders.add(javaClass.classLoader)
+ properties.put(AvailableSettings.CLASSLOADERS, classLoaders)
+
+ return properties
+ }
+
+ private fun update(
+ connection: Connection?,
+ schema: String?,
+ session: KeycloakSession,
+ updater: JpaUpdaterProvider
+ ) {
+ KeycloakModelUtils.runJobInTransaction(session.keycloakSessionFactory) { lockSession ->
+ val dbLockManager = DBLockManager(lockSession)
+ val dbLock2 = dbLockManager.dbLock
+ dbLock2.waitForLock(DBLockProvider.Namespace.DATABASE)
+ try {
+ updater.update(connection, schema)
+ } finally {
+ dbLock2.releaseLock()
+ }
+ }
+ }
+
+ private fun export(
+ connection: Connection?,
+ schema: String?,
+ databaseUpdateFile: File?,
+ session: KeycloakSession,
+ updater: JpaUpdaterProvider
+ ) {
+ KeycloakModelUtils.runJobInTransaction(session.keycloakSessionFactory) { lockSession ->
+ val dbLockManager = DBLockManager(lockSession)
+ val dbLock2 = dbLockManager.dbLock
+ dbLock2.waitForLock(DBLockProvider.Namespace.DATABASE)
+ try {
+ updater.export(connection, schema, databaseUpdateFile)
+ } finally {
+ dbLock2.releaseLock()
+ }
+ }
+ }
+
+ private fun getMigrationStrategy(): MigrationStrategy {
+ var migrationStrategy = config.get("migrationStrategy")
+ if (migrationStrategy == null) {
+ // !!! comment from keycloak code
+ // Support 'databaseSchema' for backwards compatibility
+ migrationStrategy = config.get("databaseSchema")
+ }
+
+ return if (migrationStrategy != null) {
+ MigrationStrategy.valueOf(migrationStrategy.uppercase(Locale.getDefault()))
+ } else {
+ UPDATE
+ }
+ }
+
+ private fun migrateModel(session: KeycloakSession) {
+ // !!! comment from keycloak code
+ // Using a lock to prevent concurrent migration in concurrently starting nodes
+ val dbLockManager = DBLockManager(session)
+ val dbLock = dbLockManager.dbLock
+ dbLock.waitForLock(DBLockProvider.Namespace.DATABASE)
+ try {
+ KeycloakModelUtils.runJobInTransaction(
+ session.keycloakSessionFactory
+ ) { session: KeycloakSession? -> MigrationModelManager.migrate(session) }
+ } finally {
+ dbLock.releaseLock()
+ }
+ }
+
+ private fun getDatabaseUpdateFile(): File {
+ val databaseUpdateFile = config.get("migrationExport", "keycloak-database-update.sql")
+ return File(databaseUpdateFile)
+ }
+
+ private companion object {
+ private enum class MigrationStrategy {
+ UPDATE, VALIDATE, MANUAL
+ }
+
+ const val PERSISTENCE_UNIT_NAME = "keycloak-default"
+ const val DB_KIND = "ydb"
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt
new file mode 100644
index 00000000..c6a8f52e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxy.kt
@@ -0,0 +1,43 @@
+package tech.ydb.keycloak.connection
+
+import jakarta.persistence.EntityManager
+import org.jboss.logging.Logger
+import tech.ydb.keycloak.utils.YdbRetryableResponses
+import tech.ydb.keycloak.utils.isYdbRetryable
+import java.lang.reflect.InvocationTargetException
+import java.lang.reflect.Method
+import java.lang.reflect.Proxy
+
+class YdbEntityManagerProxy(private val em: EntityManager) {
+ private fun invoke(proxy: Any, method: Method, args: Array?): Any? {
+ try {
+ return method.invoke(em, *(args ?: emptyArray()))
+ } catch (e: InvocationTargetException) {
+ val cause = e.cause ?: throw e
+ if (isYdbRetryable(cause)) {
+ LOG.warn("YDB retryable error during ${method.name}, returning 503")
+ throw YdbRetryableResponses.toWebApplicationException(cause)
+ }
+ throw cause
+ } catch (e: Exception) {
+ if (isYdbRetryable(e)) {
+ LOG.warn("YDB retryable error during ${method.name}, returning 503")
+ throw YdbRetryableResponses.toWebApplicationException(e)
+ }
+ throw e
+ }
+ }
+
+ companion object {
+ private val LOG: Logger = Logger.getLogger(YdbEntityManagerProxy::class.java)
+
+ fun create(em: EntityManager): EntityManager {
+ val proxy = YdbEntityManagerProxy(em)
+ return Proxy.newProxyInstance(
+ EntityManager::class.java.classLoader,
+ arrayOf(EntityManager::class.java),
+ proxy::invoke
+ ) as EntityManager
+ }
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt
new file mode 100644
index 00000000..2b3db18b
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransaction.kt
@@ -0,0 +1,30 @@
+package tech.ydb.keycloak.connection
+
+import jakarta.persistence.EntityManager
+import org.jboss.logging.Logger
+import org.keycloak.connections.jpa.JpaKeycloakTransaction
+import tech.ydb.keycloak.utils.YdbRetryableResponses
+import tech.ydb.keycloak.utils.isYdbRetryable
+
+class YdbJpaKeycloakTransaction(em: EntityManager) : JpaKeycloakTransaction(em) {
+
+ override fun commit() {
+ try {
+ super.commit()
+ } catch (e: Exception) {
+ if (isYdbRetryable(e)) {
+ LOG.warn("YDB retryable error during commit, returning 503")
+ throw YdbRetryableResponses.toWebApplicationException(
+ e,
+ "YDB transaction aborted",
+ YdbRetryableResponses.TRANSACTION_DESCRIPTION,
+ )
+ }
+ throw e
+ }
+ }
+
+ companion object {
+ private val LOG: Logger = Logger.getLogger(YdbJpaKeycloakTransaction::class.java)
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt
new file mode 100644
index 00000000..35466e92
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappers.kt
@@ -0,0 +1,41 @@
+package tech.ydb.keycloak.error
+
+import jakarta.persistence.PersistenceException
+import jakarta.ws.rs.core.Context
+import jakarta.ws.rs.core.Response
+import jakarta.ws.rs.ext.ExceptionMapper
+import jakarta.ws.rs.ext.Provider
+import org.hibernate.exception.GenericJDBCException
+import org.jboss.logging.Logger
+import org.keycloak.models.KeycloakSession
+import org.keycloak.services.error.KeycloakErrorHandler
+import tech.ydb.keycloak.utils.YdbRetryableResponses
+import tech.ydb.keycloak.utils.isYdbRetryable
+
+@Provider
+class YdbRetryableGenericJdbcExceptionMapper : ExceptionMapper {
+ @Context
+ private lateinit var session: KeycloakSession
+
+ override fun toResponse(exception: GenericJDBCException): Response =
+ mapOrDelegate(session, exception, "GenericJDBCException")
+}
+
+@Provider
+class YdbRetryablePersistenceExceptionMapper : ExceptionMapper {
+ @Context
+ private lateinit var session: KeycloakSession
+
+ override fun toResponse(exception: PersistenceException): Response =
+ mapOrDelegate(session, exception, "PersistenceException")
+}
+
+private fun mapOrDelegate(session: KeycloakSession, exception: Throwable, label: String): Response {
+ if (!isYdbRetryable(exception)) {
+ return KeycloakErrorHandler.getResponse(session, exception)
+ }
+ LOG.warn("YDB retryable $label, returning 503")
+ return YdbRetryableResponses.build503(exception, YdbRetryableResponses.CONTENTION_DESCRIPTION)
+}
+
+private val LOG: Logger = Logger.getLogger("tech.ydb.keycloak.error.YdbRetryableExceptionMappers")
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt
new file mode 100644
index 00000000..8d948f05
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProvider.kt
@@ -0,0 +1,188 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.Liquibase
+import liquibase.exception.DatabaseException
+import liquibase.exception.LiquibaseException
+import org.jboss.logging.Logger
+import org.keycloak.common.util.Retry
+import org.keycloak.connections.jpa.JpaConnectionProvider
+import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProvider
+import org.keycloak.connections.jpa.updater.liquibase.lock.LockRetryException
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.dblock.DBLockProvider
+import org.keycloak.models.utils.KeycloakModelUtils
+import tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl
+import java.sql.Connection
+import java.sql.SQLException
+
+class YdbDBLockProvider(
+ private val factory: YdbDBLockProviderFactory,
+ private val session: KeycloakSession
+) : DBLockProvider {
+ private companion object {
+ private const val DEFAULT_MAX_ATTEMPTS = 10
+ private const val INTERVAL_BASE_MILLIS = 10
+ }
+
+ private val logger: Logger = Logger.getLogger(YdbDBLockProvider::class.java)
+
+ private var lockService: YdbLockService? = null
+ private var dbConnection: Connection? = null
+ private var initialized = false
+ private var namespaceLocked: DBLockProvider.Namespace? = null
+
+ private fun lazyInit() {
+ if (!initialized) {
+ val liquibaseProvider = session.getProvider(LiquibaseConnectionProvider::class.java)
+ val jpaProviderFactory = session.keycloakSessionFactory
+ .getProviderFactory(JpaConnectionProvider::class.java) as YdbConnectionProviderFactoryImpl
+
+ this.dbConnection = jpaProviderFactory.connection
+ val defaultSchema = jpaProviderFactory.schema
+
+ try {
+ val liquibase: Liquibase = liquibaseProvider.getLiquibase(dbConnection, defaultSchema)
+
+ this.lockService = YdbLockService().apply {
+ setChangeLogLockWaitTime(factory.lockWaitTimeoutMillis)
+ setDatabase(liquibase.database)
+ }
+ initialized = true
+ } catch (e: LiquibaseException) {
+ safeRollbackConnection()
+ safeCloseConnection()
+ throw IllegalStateException(e)
+ }
+ }
+ }
+
+ // Assumed transaction was rolled-back and we want to start with new DB connection
+ private fun restart() {
+ safeCloseConnection()
+ lazyInit()
+ }
+
+ override fun waitForLock(lock: DBLockProvider.Namespace) {
+ KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) {
+ lazyInit()
+ if (checkNotNull(lockService).hasChangeLogLock()) {
+ if (lock == this.namespaceLocked) {
+ logger.warn("Locking namespace $lock which was already locked in this provider")
+ return@suspendJtaTransaction
+ } else {
+ throw RuntimeException(String.format("Trying to get a lock when one was already taken by the provider"))
+ }
+ }
+
+ logger.debug("Going to lock namespace=$lock")
+ Retry.executeWithBackoff({
+ checkNotNull(lockService).waitForLock(lock)
+ namespaceLocked = lock
+ }, { iteration: Int, e: Throwable? ->
+ if (e is LockRetryException && iteration < (DEFAULT_MAX_ATTEMPTS - 1)) {
+ // Indicates we should try to acquire lock again in different transaction
+ safeRollbackConnection()
+ restart()
+ } else {
+ safeRollbackConnection()
+ safeCloseConnection()
+ }
+ }, DEFAULT_MAX_ATTEMPTS, INTERVAL_BASE_MILLIS)
+ }
+ }
+
+ override fun releaseLock() {
+ KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) {
+ lazyInit()
+ logger.debug("Going to release database lock namespace=$namespaceLocked")
+ val (lockId, service) = checkLockBeforeRelease() ?: return@suspendJtaTransaction
+ try {
+ Retry.executeWithBackoff({ iteration: Int ->
+ logger.debug("Release lock attempt ${iteration + 1}")
+ service.tryReleaseLock(lockId)
+ }, { iteration: Int, _: Throwable? ->
+ if (iteration < DEFAULT_MAX_ATTEMPTS - 1) {
+ safeRollbackConnection()
+ } else {
+ safeRollbackConnection()
+ safeCloseConnection()
+ }
+ }, DEFAULT_MAX_ATTEMPTS, INTERVAL_BASE_MILLIS)
+ } catch (e: RuntimeException) {
+ logger.error("Failed to release lock after $DEFAULT_MAX_ATTEMPTS attempts", e)
+ } finally {
+ namespaceLocked = null
+ service.cleanupLockState()
+ service.reset()
+ }
+ }
+ }
+
+ override fun destroyLockInfo() {
+ KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) {
+ lazyInit()
+ try {
+ checkNotNull(lockService).destroy()
+ checkNotNull(dbConnection).commit()
+ logger.debug("Destroyed lock table")
+ } catch (_: DatabaseException) {
+ logger.error("Failed to destroy lock table")
+ safeRollbackConnection()
+ } catch (_: SQLException) {
+ logger.error("Failed to destroy lock table")
+ safeRollbackConnection()
+ }
+ }
+ }
+
+ override fun getCurrentLock(): DBLockProvider.Namespace? = namespaceLocked
+
+ override fun supportsForcedUnlock(): Boolean = false
+
+ private fun safeRollbackConnection() {
+ try {
+ dbConnection?.rollback()
+ } catch (se: SQLException) {
+ logger.warn("Failed to rollback connection after error", se)
+ }
+ }
+
+ private fun safeCloseConnection() {
+ if (dbConnection != null) {
+ try {
+ dbConnection?.close()
+ dbConnection = null
+ lockService = null
+ initialized = false
+ } catch (e: SQLException) {
+ logger.warn("Failed to close connection", e)
+ }
+ }
+ }
+
+ override fun close() {
+ KeycloakModelUtils.suspendJtaTransaction(session.keycloakSessionFactory) {
+ safeCloseConnection()
+ }
+ }
+
+ private fun checkLockBeforeRelease(): Pair? {
+ val lockId = namespaceLocked?.id
+ if (lockId == null) {
+ logger.debug("releaseLock called but no lock was held by this provider")
+ return null
+ }
+ val service = lockService
+ if (service == null) {
+ logger.error("releaseLock called but lockService is null, namespace=$namespaceLocked")
+ namespaceLocked = null
+ return null
+ }
+ if (!service.hasChangeLogLock()) {
+ logger.error("releaseLock called but lockService has no lock, namespace=$namespaceLocked")
+ namespaceLocked = null
+ return null
+ }
+ return Pair(lockId, service)
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt
new file mode 100644
index 00000000..6eabaea4
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbDBLockProviderFactory.kt
@@ -0,0 +1,56 @@
+package tech.ydb.keycloak.liquibase
+
+import org.jboss.logging.Logger
+import org.keycloak.Config
+import org.keycloak.common.util.Time
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.KeycloakSessionFactory
+import org.keycloak.models.dblock.DBLockProviderFactory
+import org.keycloak.provider.ProviderConfigProperty
+import org.keycloak.provider.ProviderConfigurationBuilder
+import tech.ydb.keycloak.config.ProviderConfig
+
+class YdbDBLockProviderFactory : DBLockProviderFactory {
+ private val logger: Logger = Logger.getLogger(YdbDBLockProviderFactory::class.java)
+
+ var lockWaitTimeoutMillis: Long = 0
+ private set
+
+ override fun init(config: Config.Scope) {
+ this.lockWaitTimeoutMillis = Time.toMillis(config.getLong("lockWaitTimeout", 900))
+ logger.debug("Liquibase lock provider configured with lockWaitTimeout: ${lockWaitTimeoutMillis}ms")
+ }
+
+ override fun create(session: KeycloakSession): YdbDBLockProvider {
+ return YdbDBLockProvider(this, session)
+ }
+
+ override fun postInit(factory: KeycloakSessionFactory?) {
+ // no operations
+ }
+
+ override fun setTimeouts(lockRecheckTimeMillis: Long, lockWaitTimeoutMillis: Long) {
+ this.lockWaitTimeoutMillis = lockWaitTimeoutMillis
+ }
+
+ override fun close() {
+ // no operations
+ }
+
+ override fun getConfigMetadata(): MutableList? {
+ return ProviderConfigurationBuilder.create()
+ .property()
+ .name("lockWaitTimeout")
+ .type("int")
+ .helpText("The maximum time to wait when waiting to release a database lock.")
+ .add().build()
+ }
+
+ override fun order(): Int {
+ return ProviderConfig.PROVIDER_PRIORITY
+ }
+
+ override fun getId(): String {
+ return ProviderConfig.PROVIDER_ID
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt
new file mode 100644
index 00000000..3a9ae424
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLiquibaseConnectionProvider.kt
@@ -0,0 +1,77 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.database.AbstractJdbcDatabase
+import liquibase.database.Database
+import liquibase.database.jvm.JdbcConnection
+import liquibase.resource.ClassLoaderResourceAccessor
+import liquibase.resource.ResourceAccessor
+import org.jboss.logging.Logger
+import org.keycloak.Config
+import org.keycloak.connections.jpa.updater.liquibase.conn.DefaultLiquibaseConnectionProvider
+import org.keycloak.connections.jpa.updater.liquibase.conn.KeycloakLiquibase
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY
+import tech.ydb.liquibase.database.YdbDatabase
+import java.sql.Connection
+
+class YdbLiquibaseConnectionProvider : DefaultLiquibaseConnectionProvider() {
+
+ private var indexCreationThreshold: Long = DEFAULT_INDEX_CREATION_THRESHOLD
+
+ override fun init(config: Config.Scope) {
+ indexCreationThreshold = config.getLong("indexCreationThreshold", DEFAULT_INDEX_CREATION_THRESHOLD)
+ logger.debugf("indexCreationThreshold is %d", indexCreationThreshold)
+ }
+
+ override fun getId(): String = PROVIDER_ID
+
+ override fun order(): Int = PROVIDER_PRIORITY
+
+ override fun getLiquibase(connection: Connection, defaultSchema: String?): KeycloakLiquibase {
+ val database = newYdbDatabase(connection)
+ if (!defaultSchema.isNullOrBlank()) {
+ database.defaultSchemaName = defaultSchema
+ }
+ val resourceAccessor: ResourceAccessor = ClassLoaderResourceAccessor(javaClass.classLoader)
+ logger.debugf(
+ "Using YDB Liquibase changelog %s and changelogTableName %s",
+ YDB_MASTER_CHANGELOG,
+ database.databaseChangeLogTableName
+ )
+ (database as AbstractJdbcDatabase).set(
+ INDEX_CREATION_THRESHOLD_PARAM,
+ indexCreationThreshold
+ )
+ return KeycloakLiquibase(YDB_MASTER_CHANGELOG, resourceAccessor, database)
+ }
+
+ override fun getLiquibaseForCustomUpdate(
+ connection: Connection,
+ defaultSchema: String?,
+ changelogLocation: String,
+ classloader: ClassLoader,
+ changelogTableName: String
+ ): KeycloakLiquibase {
+ val database = newYdbDatabase(connection)
+ if (!defaultSchema.isNullOrBlank()) {
+ database.defaultSchemaName = defaultSchema
+ }
+ val resourceAccessor = ClassLoaderResourceAccessor(classloader)
+ database.databaseChangeLogTableName = changelogTableName
+
+ logger.debugf("Using YDB Liquibase for custom update $changelogLocation and changelogTableName ${database.databaseChangeLogTableName}")
+
+ return KeycloakLiquibase(changelogLocation, resourceAccessor, database)
+ }
+
+ private fun newYdbDatabase(connection: Connection): Database = YdbDatabase().also {
+ it.connection = JdbcConnection(connection)
+ }
+
+ companion object {
+ const val YDB_MASTER_CHANGELOG: String = "ydb/db.changelog-master.xml"
+
+ private const val DEFAULT_INDEX_CREATION_THRESHOLD = 300000L
+ private val logger = Logger.getLogger(YdbLiquibaseConnectionProvider::class.java)
+ }
+}
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt
new file mode 100644
index 00000000..80a5a752
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockService.kt
@@ -0,0 +1,204 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.Scope
+import liquibase.exception.DatabaseException
+import liquibase.executor.ExecutorService
+import liquibase.lockservice.StandardLockService
+import liquibase.statement.core.DeleteStatement
+import liquibase.statement.core.RawSqlStatement
+import liquibase.statement.core.LockDatabaseChangeLogStatement
+import org.jboss.logging.Logger
+import org.keycloak.common.util.Time
+import org.keycloak.common.util.reflections.Reflections
+import org.keycloak.connections.jpa.updater.liquibase.LiquibaseConstants
+import org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockDatabaseChangeLogStatement
+import org.keycloak.connections.jpa.updater.liquibase.lock.LockRetryException
+import org.keycloak.models.dblock.DBLockProvider
+import tech.ydb.liquibase.lockservice.StandardLockServiceYdb
+
+class YdbLockService : StandardLockServiceYdb() {
+ private val log: Logger = Logger.getLogger(YdbLockService::class.java)
+
+ override fun init() {
+ val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java)
+ .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database)
+
+ if (!isDatabaseChangeLogLockTableCreated) {
+ try {
+ log.trace("Create Database Lock Table")
+ executor.execute(RawSqlStatement(buildCreateLockTableSql()))
+ database.commit()
+ } catch (de: DatabaseException) {
+ log.warn("Failed to create lock table. Maybe other transaction created in the meantime. Retrying...", de)
+ log.trace(de.message, de)
+ database.rollback()
+ throw LockRetryException(de)
+ }
+
+ log.debug("Created database lock table")
+
+ try {
+ val field = Reflections.findDeclaredField(StandardLockService::class.java, "hasDatabaseChangeLogLockTable")
+ Reflections.setAccessible(field)
+ field.set(this@YdbLockService, true)
+ } catch (iae: IllegalAccessException) {
+ throw RuntimeException(iae)
+ }
+ }
+
+ // Clean up any LOCKED=false rows left by manual intervention.
+ // With INSERT/DELETE semantics, only LOCKED=true rows (actively held locks) should exist.
+ try {
+ executor.execute(
+ DeleteStatement(database.liquibaseCatalogName, database.liquibaseSchemaName, database.databaseChangeLogLockTableName)
+ .setWhere("LOCKED = false")
+ )
+ database.commit()
+ } catch (de: DatabaseException) {
+ database.rollback()
+ throw LockRetryException(de)
+ }
+ }
+
+ override fun waitForLock() {
+ waitForLock(LockDatabaseChangeLogStatement())
+ }
+
+ fun waitForLock(lock: DBLockProvider.Namespace) {
+ waitForLock(CustomLockDatabaseChangeLogStatement(lock.id))
+ }
+
+ private fun waitForLock(lockStmt: LockDatabaseChangeLogStatement) {
+ var locked = false
+ val startTime = Time.toMillis(Time.currentTime().toLong())
+ val timeToGiveUp = startTime + (getChangeLogLockWaitTime())
+ var nextAttempt = true
+
+ while (nextAttempt) {
+ locked = acquireLock(lockStmt)
+ if (!locked) {
+ val remainingTime = ((timeToGiveUp / 1000).toInt()) - Time.currentTime()
+ if (remainingTime > 0) {
+ log.debug("Will try to acquire log another time. Remaining time: $remainingTime seconds")
+ } else {
+ nextAttempt = false
+ }
+ } else {
+ nextAttempt = false
+ }
+ }
+
+ if (!locked) {
+ val timeout = ((getChangeLogLockWaitTime() / 1000).toInt())
+ throw IllegalStateException("Could not acquire change log lock within specified timeout $timeout seconds. Currently locked by other transaction")
+ }
+ }
+
+ override fun acquireLock(): Boolean {
+ return acquireLock(LockDatabaseChangeLogStatement())
+ }
+
+ private fun acquireLock(lockStmt: LockDatabaseChangeLogStatement): Boolean {
+ if (hasChangeLogLock) {
+ return true
+ }
+
+ val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java)
+ .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database)
+
+ try {
+ database.rollback()
+ this.init()
+ } catch (de: DatabaseException) {
+ throw IllegalStateException("Failed to retrieve lock", de)
+ }
+
+ val id = if (lockStmt is CustomLockDatabaseChangeLogStatement) lockStmt.id else DEFAULT_LOCK_ID
+
+ return try {
+ log.debug("Trying to acquire lock id=$id")
+ executor.execute(YdbLockStatement(id))
+ database.commit()
+
+ hasChangeLogLock = true
+ database.setCanCacheLiquibaseTableInfo(true)
+ log.debug("Successfully acquired lock id=$id")
+ true
+ } catch (de: DatabaseException) {
+ log.debug("Lock id=$id is held by another transaction, will retry. Details: ${de.message}")
+ try {
+ database.rollback()
+ } catch (_: DatabaseException) {
+ // no operations
+ }
+ false
+ }
+ }
+
+
+ fun tryReleaseLock(lockId: Int) {
+ log.debug("Going to release database lock id=$lockId")
+ val executor = Scope.getCurrentScope().getSingleton(ExecutorService::class.java)
+ .getExecutor(LiquibaseConstants.JDBC_EXECUTOR, database)
+
+ database.rollback()
+ try {
+ executor.execute(YdbUnlockStatement(lockId))
+ database.commit()
+ } catch (e: Exception) {
+ throw RuntimeException("Failed to release lock id=$lockId", e)
+ }
+ }
+
+ fun cleanupLockState() {
+ try {
+ hasChangeLogLock = false
+ database.setCanCacheLiquibaseTableInfo(false)
+ database.rollback()
+ } catch (_: DatabaseException) {
+ // no operations
+ }
+ }
+
+ override fun releaseLock() {
+ releaseLock(DEFAULT_LOCK_ID)
+ }
+
+ fun releaseLock(lockId: Int) {
+ try {
+ if (hasChangeLogLock) {
+ tryReleaseLock(lockId)
+ } else {
+ log.warn("Attempt to release lock, which is not owned by current transaction")
+ }
+ } catch (e: Exception) {
+ log.error("Database error during release lock", e)
+ } finally {
+ cleanupLockState()
+ }
+ }
+
+ /**
+ * YQL for Liquibase lock table (see [tech.ydb.liquibase.sqlgenerator.CreateDatabaseChangeLogLockTableGeneratorYdb]).
+ * Raw SQL is used here because generic Liquibase emits PostgreSQL DDL (INT, BOOLEAN, VARCHAR),
+ * which YDB rejects.
+ */
+ private fun buildCreateLockTableSql(): String {
+ val tableName = database.escapeTableName(
+ database.liquibaseCatalogName,
+ database.liquibaseSchemaName,
+ database.databaseChangeLogLockTableName
+ )
+ return "CREATE TABLE $tableName (" +
+ "ID INT32, " +
+ "LOCKED BOOL, " +
+ "LOCKGRANTED DATETIME, " +
+ "LOCKEDBY TEXT, " +
+ "PRIMARY KEY(ID)" +
+ ")"
+ }
+
+ companion object {
+ private val DEFAULT_LOCK_ID = DBLockProvider.Namespace.DATABASE.id
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt
new file mode 100644
index 00000000..d9faeb50
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockSqlGenerator.kt
@@ -0,0 +1,61 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.database.Database
+import liquibase.exception.ValidationErrors
+import liquibase.sql.Sql
+import liquibase.sqlgenerator.SqlGeneratorChain
+import liquibase.sqlgenerator.SqlGeneratorFactory
+import liquibase.sqlgenerator.core.LockDatabaseChangeLogGenerator
+import liquibase.statement.DatabaseFunction
+import liquibase.statement.core.InsertStatement
+import liquibase.statement.core.LockDatabaseChangeLogStatement
+import tech.ydb.liquibase.database.YdbDatabase
+
+/**
+ * Generates an INSERT-based lock acquisition for YDB.
+ *
+ * YDB does not support SELECT FOR UPDATE. Instead, we use INSERT: inserting
+ * a row succeeds only when no row with that ID exists (lock is free). If the row
+ * already exists (lock is held), the INSERT fails with a primary-key violation,
+ * which the caller catches and interprets as "lock busy, retry later".
+ *
+ * Lock table invariant: absence of a row means "unlocked"; presence means "locked".
+ * The table is kept empty during init — see YdbLockService.init().
+ * Release is done by DELETE — see YdbUnlockSqlGenerator.
+ *
+ * Extends LockDatabaseChangeLogGenerator to inherit hostname/hostaddress/hostDescription
+ * static fields
+ */
+class YdbLockSqlGenerator : LockDatabaseChangeLogGenerator() {
+
+ override fun getPriority(): Int = PRIORITY_DATABASE
+
+ override fun supports(statement: LockDatabaseChangeLogStatement, database: Database): Boolean =
+ statement is YdbLockStatement && database is YdbDatabase
+
+ override fun validate(
+ statement: LockDatabaseChangeLogStatement,
+ database: Database,
+ chain: SqlGeneratorChain<*>
+ ): ValidationErrors = ValidationErrors()
+
+ override fun generateSql(
+ statement: LockDatabaseChangeLogStatement,
+ database: Database,
+ chain: SqlGeneratorChain<*>
+ ): Array {
+ statement as YdbLockStatement
+
+ val insertStatement = InsertStatement(
+ database.liquibaseCatalogName,
+ database.liquibaseSchemaName,
+ database.databaseChangeLogLockTableName
+ )
+ .addColumnValue("ID", statement.id)
+ .addColumnValue("LOCKED", true)
+ .addColumnValue("LOCKGRANTED", DatabaseFunction("CurrentUtcDatetime()"))
+ .addColumnValue("LOCKEDBY", "$hostname$hostDescription ($hostaddress)")
+
+ return SqlGeneratorFactory.getInstance().generateSql(insertStatement, database)
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt
new file mode 100644
index 00000000..a04c2cea
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbLockStatement.kt
@@ -0,0 +1,5 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.statement.core.LockDatabaseChangeLogStatement
+
+class YdbLockStatement(val id: Int) : LockDatabaseChangeLogStatement()
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt
new file mode 100644
index 00000000..5113620e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockSqlGenerator.kt
@@ -0,0 +1,44 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.database.Database
+import liquibase.exception.ValidationErrors
+import liquibase.sql.Sql
+import liquibase.sqlgenerator.SqlGeneratorChain
+import liquibase.sqlgenerator.SqlGeneratorFactory
+import liquibase.sqlgenerator.core.AbstractSqlGenerator
+import liquibase.statement.core.DeleteStatement
+import tech.ydb.liquibase.database.YdbDatabase
+
+/**
+ * Generates a DELETE-based lock release for YDB.
+ *
+ * The lock table invariant is: presence of a row = locked, absence = unlocked.
+ * Deleting the row releases the lock and makes it available for the next process.
+ */
+class YdbUnlockSqlGenerator : AbstractSqlGenerator() {
+
+ override fun getPriority(): Int = PRIORITY_DATABASE
+
+ override fun supports(statement: YdbUnlockStatement, database: Database): Boolean =
+ database is YdbDatabase
+
+ override fun validate(
+ statement: YdbUnlockStatement,
+ database: Database,
+ chain: SqlGeneratorChain
+ ): ValidationErrors = ValidationErrors()
+
+ override fun generateSql(
+ statement: YdbUnlockStatement,
+ database: Database,
+ chain: SqlGeneratorChain
+ ): Array {
+ val deleteStatement = DeleteStatement(
+ database.liquibaseCatalogName,
+ database.liquibaseSchemaName,
+ database.databaseChangeLogLockTableName
+ ).setWhere("ID = ${statement.id}")
+
+ return SqlGeneratorFactory.getInstance().generateSql(deleteStatement, database)
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt
new file mode 100644
index 00000000..0d6c1c50
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/liquibase/YdbUnlockStatement.kt
@@ -0,0 +1,5 @@
+package tech.ydb.keycloak.liquibase
+
+import liquibase.statement.core.UnlockDatabaseChangeLogStatement
+
+class YdbUnlockStatement(val id: Int) : UnlockDatabaseChangeLogStatement()
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt
new file mode 100644
index 00000000..c5d8ec2c
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProvider.kt
@@ -0,0 +1,143 @@
+package tech.ydb.keycloak.realm
+
+import jakarta.persistence.EntityManager
+import org.keycloak.common.util.StackUtil
+import org.keycloak.models.ClientModel
+import org.keycloak.models.ClientModel.ClientRemovedEvent
+import org.keycloak.models.ClientScopeModel
+import org.keycloak.models.ClientScopeModel.ClientScopeRemovedEvent
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.RealmModel
+import org.keycloak.models.jpa.JpaRealmProvider
+import org.keycloak.models.jpa.RealmAdapter
+import org.keycloak.models.jpa.entities.ClientEntity
+import org.keycloak.models.jpa.entities.ClientScopeEntity
+import org.keycloak.models.jpa.entities.RealmEntity
+
+
+/**
+ * Realm provider for YDB. Overrides some functions to load the realm entity without
+ * PESSIMISTIC_WRITE lock, because YDB does not support FOR UPDATE.
+ */
+class YdbRealmProvider(
+ private val keycloakSession: KeycloakSession,
+ entityManager: EntityManager,
+ clientSearchableAttributes: Set? = null,
+ groupSearchableAttributes: Set? = null,
+) : JpaRealmProvider(
+ keycloakSession,
+ entityManager,
+ clientSearchableAttributes,
+ groupSearchableAttributes,
+) {
+
+ override fun removeRealm(id: String): Boolean {
+ // YDB does not support FOR UPDATE (PESSIMISTIC_WRITE)
+ val realm = em.find(RealmEntity::class.java, id) ?: return false
+ val adapter = RealmAdapter(keycloakSession, em, realm)
+ keycloakSession.users().preRemove(adapter)
+
+ realm.defaultGroupIds.clear()
+ em.flush()
+
+ keycloakSession.clients().removeClients(adapter)
+ em.createNamedQuery("deleteDefaultClientScopeRealmMappingByRealm")
+ .setParameter("realm", realm).executeUpdate()
+
+ keycloakSession.clientScopes().removeClientScopes(adapter)
+ keycloakSession.roles().removeRoles(adapter)
+
+ em.createNamedQuery("deleteOrganizationDomainsByRealm")
+ .setParameter("realmId", realm.id).executeUpdate()
+ em.createNamedQuery("deleteOrganizationsByRealm")
+ .setParameter("realmId", realm.id).executeUpdate()
+ keycloakSession.groups().preRemove(adapter)
+
+ keycloakSession.identityProviders().removeAll()
+ keycloakSession.identityProviders().removeAllMappers()
+
+ em.createNamedQuery("removeClientInitialAccessByRealm")
+ .setParameter("realm", realm).executeUpdate()
+
+ em.remove(realm)
+ em.flush()
+ em.clear()
+
+ val session = keycloakSession
+ keycloakSession.keycloakSessionFactory.publish(object : RealmModel.RealmRemovedEvent {
+ override fun getRealm(): RealmModel = adapter
+ override fun getKeycloakSession(): KeycloakSession = session
+ })
+ return true
+ }
+
+ override fun removeClient(realm: RealmModel, id: String?): Boolean {
+ logger.tracef("removeClient(%s, %s)%s", realm, id, StackUtil.getShortStackTrace())
+
+ val client = getClientById(realm, id) ?: return false
+
+ keycloakSession.users().preRemove(realm, client)
+ keycloakSession.roles().removeRoles(client)
+
+ // YDB does not support FOR UPDATE (PESSIMISTIC_WRITE)
+ val clientEntity = em.find(ClientEntity::class.java, id)
+
+ keycloakSession.keycloakSessionFactory.publish(object : ClientRemovedEvent {
+ override fun getClient(): ClientModel {
+ return client
+ }
+
+ override fun getKeycloakSession(): KeycloakSession {
+ // without `this@YdbRealmProvider` here will be infinite recursion
+ return this@YdbRealmProvider.keycloakSession
+ }
+ })
+
+ val countRemoved = em.createNamedQuery("deleteClientScopeClientMappingByClient")
+ .setParameter("clientId", clientEntity.id)
+ .executeUpdate()
+
+ // !!! comment from JpaRealmProvider)))
+ // i have no idea why, but this needs to come before deleteScopeMapping
+ em.remove(clientEntity)
+
+ try {
+ em.flush()
+ } catch (e: RuntimeException) {
+ logger.errorv("Unable to delete client entity: {0} from realm {1}", client.clientId, realm.name)
+ throw e
+ }
+
+ return true
+ }
+
+ override fun removeClientScope(realm: RealmModel, id: String?): Boolean {
+ if (id == null) return false
+ val clientScope = getClientScopeById(realm, id)
+ if (clientScope == null) return false
+
+ keycloakSession.users().preRemove(clientScope)
+ realm.removeDefaultClientScope(clientScope)
+ // YDB does not support FOR UPDATE (PESSIMISTIC_WRITE)
+ val clientScopeEntity = em.find(ClientScopeEntity::class.java, id)
+
+ em.createNamedQuery("deleteClientScopeClientMappingByClientScope")
+ .setParameter("clientScopeId", clientScope.id).executeUpdate()
+ em.createNamedQuery("deleteClientScopeRoleMappingByClientScope").setParameter("clientScope", clientScopeEntity)
+ .executeUpdate()
+ em.remove(clientScopeEntity)
+
+ keycloakSession.keycloakSessionFactory.publish(object : ClientScopeRemovedEvent {
+ override fun getKeycloakSession(): KeycloakSession {
+ return this@YdbRealmProvider.keycloakSession
+ }
+
+ override fun getClientScope(): ClientScopeModel {
+ return clientScope
+ }
+ })
+
+ em.flush()
+ return true
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt
new file mode 100644
index 00000000..1e7fe163
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/realm/YdbRealmProviderFactory.kt
@@ -0,0 +1,41 @@
+package tech.ydb.keycloak.realm
+
+import org.jboss.logging.Logger
+import org.keycloak.Config
+import org.keycloak.connections.jpa.JpaConnectionProvider
+import org.keycloak.models.KeycloakSession
+import org.keycloak.models.KeycloakSessionFactory
+import org.keycloak.models.RealmProviderFactory
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_ID
+import tech.ydb.keycloak.config.ProviderConfig.PROVIDER_PRIORITY
+
+class YdbRealmProviderFactory() : RealmProviderFactory {
+
+ private val logger = Logger.getLogger(YdbRealmProviderFactory::class.java)
+
+ override fun create(session: KeycloakSession): YdbRealmProvider {
+ val provider = session.getProvider(JpaConnectionProvider::class.java)?.let {
+ YdbRealmProvider(session, it.entityManager)
+ } ?: error("JpaConnectionProvider is not configured in YDB")
+
+ logger.info("YdbRealmProvider successfully created")
+
+ return provider
+ }
+
+ override fun init(scope: Config.Scope) {
+ // no operations
+ }
+
+ override fun postInit(p0: KeycloakSessionFactory?) {
+ // no operations
+ }
+
+ override fun close() {
+ // no operations
+ }
+
+ override fun getId(): String = PROVIDER_ID
+
+ override fun order(): Int = PROVIDER_PRIORITY
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt
new file mode 100644
index 00000000..8e003912
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbErrorUtils.kt
@@ -0,0 +1,12 @@
+package tech.ydb.keycloak.utils
+
+import tech.ydb.jdbc.exception.YdbRetryableException
+
+fun isYdbRetryable(t: Throwable): Boolean {
+ var cause: Throwable? = t
+ while (cause != null) {
+ if (cause is YdbRetryableException) return true
+ cause = cause.cause
+ }
+ return false
+}
diff --git a/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt
new file mode 100644
index 00000000..ab9ad8bb
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponses.kt
@@ -0,0 +1,24 @@
+package tech.ydb.keycloak.utils
+
+import jakarta.ws.rs.WebApplicationException
+import jakarta.ws.rs.core.MediaType
+import jakarta.ws.rs.core.Response
+
+object YdbRetryableResponses {
+ const val ERROR_CODE = "ydb_retryable"
+
+ const val CONTENTION_DESCRIPTION = "Transaction aborted due to contention, please retry"
+ const val TRANSACTION_DESCRIPTION = "Transaction aborted, please retry"
+
+ fun build503(cause: Throwable, errorDescription: String): Response =
+ Response.status(Response.Status.SERVICE_UNAVAILABLE)
+ .entity("""{"error":"$ERROR_CODE","error_description":"$errorDescription"}""")
+ .type(MediaType.APPLICATION_JSON_TYPE)
+ .build()
+
+ fun toWebApplicationException(
+ cause: Throwable,
+ message: String = cause.message ?: ERROR_CODE,
+ errorDescription: String = CONTENTION_DESCRIPTION,
+ ): WebApplicationException = WebApplicationException(message, cause, build503(cause, errorDescription))
+}
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties b/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties
new file mode 100644
index 00000000..56b7dc08
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/queries-ydb.properties
@@ -0,0 +1,38 @@
+# YDB-specific named query overrides.
+#
+# 1. YDB requires each JOIN ON predicate to reference columns from both sides of the join.
+# These queries move filter conditions (column = :param) from ON to WHERE.
+# 2. YDB does not support implicit cross joins (comma-separated FROM).
+# These queries replace "FROM A, B WHERE b = a.field" with explicit JOINs.
+
+findUserSessionsByClientId[jpql]=SELECT sess FROM PersistentUserSessionEntity sess \
+ INNER JOIN PersistentClientSessionEntity clientSess \
+ ON sess.userSessionId = clientSess.userSessionId AND sess.offline = clientSess.offline \
+ WHERE clientSess.clientId = :clientId \
+ AND sess.offline = :offline \
+ AND sess.realmId = :realmId \
+ AND sess.lastSessionRefresh >= :lastSessionRefresh \
+ ORDER BY sess.userSessionId
+
+findUserSessionsByExternalClientId[jpql]=SELECT sess FROM PersistentUserSessionEntity sess \
+ INNER JOIN PersistentClientSessionEntity clientSess \
+ ON sess.userSessionId = clientSess.userSessionId AND sess.offline = clientSess.offline \
+ WHERE clientSess.clientStorageProvider = :clientStorageProvider \
+ AND clientSess.externalClientId = :externalClientId \
+ AND sess.offline = :offline \
+ AND sess.realmId = :realmId \
+ AND sess.lastSessionRefresh >= :lastSessionRefresh \
+ ORDER BY sess.userSessionId
+
+# Original: select sess from PersistentUserSessionEntity sess, RealmEntity realm where realm.id = sess.realmId ...
+# The RealmEntity join is a no-op filter; YDB rejects implicit cross join.
+findUserSessionsOrderedById[jpql]=SELECT sess FROM PersistentUserSessionEntity sess \
+ WHERE sess.offline = :offline \
+ AND sess.userSessionId > :lastSessionId \
+ ORDER BY sess.userSessionId
+
+# Original: select u from UserRoleMappingEntity m, UserEntity u where m.roleId=:roleId and u=m.user order by u.username
+# YDB does not support implicit cross join (comma-separated FROM).
+usersInRole[jpql]=SELECT m.user FROM UserRoleMappingEntity m \
+ WHERE m.roleId = :roleId \
+ ORDER BY m.user.username
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator
new file mode 100644
index 00000000..c52883eb
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/liquibase.sqlgenerator.SqlGenerator
@@ -0,0 +1,2 @@
+tech.ydb.keycloak.liquibase.YdbLockSqlGenerator
+tech.ydb.keycloak.liquibase.YdbUnlockSqlGenerator
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory
new file mode 100644
index 00000000..9348d601
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.JpaConnectionProviderFactory
@@ -0,0 +1 @@
+tech.ydb.keycloak.connection.YdbConnectionProviderFactoryImpl
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory
new file mode 100644
index 00000000..9b4b14c7
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionProviderFactory
@@ -0,0 +1 @@
+tech.ydb.keycloak.liquibase.YdbLiquibaseConnectionProvider
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory
new file mode 100644
index 00000000..07dfd04f
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory
@@ -0,0 +1 @@
+tech.ydb.keycloak.client.YdbClientProviderFactory
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory
new file mode 100644
index 00000000..ec4888dd
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.ClientScopeProviderFactory
@@ -0,0 +1 @@
+tech.ydb.keycloak.client.YdbClientScopeProviderFactory
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory
new file mode 100644
index 00000000..fb8ab02d
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.RealmProviderFactory
@@ -0,0 +1 @@
+tech.ydb.keycloak.realm.YdbRealmProviderFactory
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory
new file mode 100644
index 00000000..37336fe8
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/META-INF/services/org.keycloak.models.dblock.DBLockProviderFactory
@@ -0,0 +1 @@
+tech.ydb.keycloak.liquibase.YdbDBLockProviderFactory
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql
new file mode 100644
index 00000000..2d04eeb8
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-07-create-realm-table.sql
@@ -0,0 +1,60 @@
+CREATE TABLE IF NOT EXISTS REALM
+(
+ `ID` Utf8 NOT NULL,
+ `ACCESS_CODE_LIFESPAN` Int32,
+ `USER_ACTION_LIFESPAN` Int32,
+ `ACCESS_TOKEN_LIFESPAN` Int32,
+ `ACCOUNT_THEME` Utf8,
+ `ADMIN_THEME` Utf8,
+ `EMAIL_THEME` Utf8,
+ `ENABLED` Bool NOT NULL DEFAULT false,
+ `EVENTS_ENABLED` Bool NOT NULL DEFAULT false,
+ `EVENTS_EXPIRATION` Int64,
+ `LOGIN_THEME` Utf8,
+ `NAME` Utf8,
+ `NOT_BEFORE` Int32,
+ `PASSWORD_POLICY` Utf8,
+ `REGISTRATION_ALLOWED` Bool NOT NULL DEFAULT false,
+ `REMEMBER_ME` Bool NOT NULL DEFAULT false,
+ `RESET_PASSWORD_ALLOWED` Bool NOT NULL DEFAULT false,
+ `SOCIAL` Bool NOT NULL DEFAULT false,
+ `SSL_REQUIRED` Utf8,
+ `SSO_IDLE_TIMEOUT` Int32,
+ `SSO_MAX_LIFESPAN` Int32,
+ `UPDATE_PROFILE_ON_SOC_LOGIN` Bool NOT NULL DEFAULT false,
+ `VERIFY_EMAIL` Bool NOT NULL DEFAULT false,
+ `MASTER_ADMIN_CLIENT` Utf8,
+ `LOGIN_LIFESPAN` Int32,
+ `INTERNATIONALIZATION_ENABLED` Bool NOT NULL DEFAULT false,
+ `DEFAULT_LOCALE` Utf8,
+ `REG_EMAIL_AS_USERNAME` Bool NOT NULL DEFAULT false,
+ `ADMIN_EVENTS_ENABLED` Bool NOT NULL DEFAULT false,
+ `ADMIN_EVENTS_DETAILS_ENABLED` Bool NOT NULL DEFAULT false,
+ `EDIT_USERNAME_ALLOWED` Bool NOT NULL DEFAULT false,
+ `OTP_POLICY_COUNTER` Int32 DEFAULT 0,
+ `OTP_POLICY_WINDOW` Int32 DEFAULT 1,
+ `OTP_POLICY_PERIOD` Int32 DEFAULT 30,
+ `OTP_POLICY_DIGITS` Int32 DEFAULT 6,
+ `OTP_POLICY_ALG` Utf8 DEFAULT 'HmacSHA1',
+ `OTP_POLICY_TYPE` Utf8 DEFAULT 'totp',
+ `BROWSER_FLOW` Utf8,
+ `REGISTRATION_FLOW` Utf8,
+ `DIRECT_GRANT_FLOW` Utf8,
+ `RESET_CREDENTIALS_FLOW` Utf8,
+ `CLIENT_AUTH_FLOW` Utf8,
+ `OFFLINE_SESSION_IDLE_TIMEOUT` Int32 NOT NULL DEFAULT 0,
+ `REVOKE_REFRESH_TOKEN` Bool NOT NULL DEFAULT false,
+ `ACCESS_TOKEN_LIFE_IMPLICIT` Int32 NOT NULL DEFAULT 0,
+ `LOGIN_WITH_EMAIL_ALLOWED` Bool NOT NULL DEFAULT true,
+ `DUPLICATE_EMAILS_ALLOWED` Bool NOT NULL DEFAULT false,
+ `DOCKER_AUTH_FLOW` Utf8,
+ `REFRESH_TOKEN_MAX_REUSE` Int32 NOT NULL DEFAULT 0,
+ `ALLOW_USER_MANAGED_ACCESS` Bool NOT NULL DEFAULT false,
+ `SSO_MAX_LIFESPAN_REMEMBER_ME` Int32 NOT NULL DEFAULT 0,
+ `SSO_IDLE_TIMEOUT_REMEMBER_ME` Int32 NOT NULL DEFAULT 0,
+ `DEFAULT_ROLE` Utf8,
+
+ INDEX realm_name_unique GLOBAL UNIQUE ON (NAME),
+ INDEX idx_realm_master_adm_cli GLOBAL ON (MASTER_ADMIN_CLIENT),
+ PRIMARY KEY (ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql
new file mode 100644
index 00000000..ac933656
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-21-29-create-realm-attributes-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS REALM_ATTRIBUTE
+(
+ NAME Utf8 NOT NULL,
+ REALM_ID Utf8 NOT NULL,
+ VALUE Utf8,
+
+ INDEX realm_attributes_idx_realm_id GLOBAL ON (REALM_ID),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (NAME, REALM_ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql
new file mode 100644
index 00000000..efea8a69
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-17-22-00-create-realm-required-credentials-table.sql
@@ -0,0 +1,11 @@
+create table IF NOT EXISTS REALM_REQUIRED_CREDENTIAL
+(
+ REALM_ID Utf8 not null,
+ TYPE Utf8 not null,
+ FORM_LABEL Utf8,
+ INPUT Bool not null default false,
+ SECRET Bool not null default false,
+
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (REALM_ID, TYPE)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql
new file mode 100644
index 00000000..3880b73e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-20-create-realm_events_listeners-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS REALM_EVENTS_LISTENERS
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `VALUE` Utf8 NOT NULL,
+
+ INDEX idx_realm_evt_list_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`REALM_ID`, `VALUE`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql
new file mode 100644
index 00000000..e4010123
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-21-create-realm-default-groups-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS `REALM_DEFAULT_GROUPS`
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `GROUP_ID` Utf8 NOT NULL,
+
+ INDEX idx_realm_def_grp_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`REALM_ID`, `GROUP_ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql
new file mode 100644
index 00000000..de7385ec
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-28-create-realm-enabled-event-types-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS `REALM_ENABLED_EVENT_TYPES`
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `VALUE` Utf8 NOT NULL,
+
+ INDEX idx_realm_evt_types_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`REALM_ID`, `VALUE`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql
new file mode 100644
index 00000000..3fb00d41
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-29-create-realm-localizations-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS `REALM_LOCALIZATIONS`
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `LOCALE` Utf8 NOT NULL,
+ `TEXTS` Text NOT NULL,
+
+ PRIMARY KEY (`REALM_ID`, `LOCALE`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql
new file mode 100644
index 00000000..9da6db8b
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-30-create-realm-smtp-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS `REALM_SMTP_CONFIG`
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`REALM_ID`, `NAME`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql
new file mode 100644
index 00000000..899fdbf9
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-31-create-realm-supported-locales-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS `REALM_SUPPORTED_LOCALES`
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `VALUE` Utf8 NOT NULL,
+
+ INDEX idx_realm_supp_local_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`REALM_ID`, `VALUE`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql
new file mode 100644
index 00000000..16fc19de
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-34-create-default-client-scope-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS `DEFAULT_CLIENT_SCOPE`
+(
+ `REALM_ID` Utf8 NOT NULL,
+ `SCOPE_ID` Utf8 NOT NULL,
+ `DEFAULT_SCOPE` Bool NOT NULL DEFAULT false,
+
+ INDEX idx_defcls_realm GLOBAL ON (`REALM_ID`),
+ INDEX idx_defcls_scope GLOBAL ON (`SCOPE_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`REALM_ID`, `SCOPE_ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql
new file mode 100644
index 00000000..b788e5ea
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-36-create-authentication-flow-table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS `AUTHENTICATION_FLOW`
+(
+ `ID` Utf8 NOT NULL,
+ `ALIAS` Utf8,
+ `DESCRIPTION` Utf8,
+ `REALM_ID` Utf8,
+ `PROVIDER_ID` Utf8 NOT NULL DEFAULT "basic-flow",
+ `TOP_LEVEL` Bool NOT NULL DEFAULT false,
+ `BUILT_IN` Bool NOT NULL DEFAULT false,
+
+ INDEX idx_auth_flow_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql
new file mode 100644
index 00000000..56ef1b79
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-38-create-authentication-execution-table.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS `AUTHENTICATION_EXECUTION`
+(
+ `ID` Utf8 NOT NULL,
+ `ALIAS` Utf8,
+ `AUTHENTICATOR` Utf8,
+ `REALM_ID` Utf8,
+ `FLOW_ID` Utf8,
+ `REQUIREMENT` Int32,
+ `PRIORITY` Int32,
+ `AUTHENTICATOR_FLOW` Bool NOT NULL DEFAULT false,
+ `AUTH_FLOW_ID` Utf8,
+ `AUTH_CONFIG` Utf8,
+
+ INDEX idx_auth_exec_realm_flow GLOBAL ON (`REALM_ID`, `FLOW_ID`),
+ INDEX idx_auth_exec_flow GLOBAL ON (`FLOW_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+-- FOREIGN KEY (FLOW_ID) REFERENCES AUTHENTICATION_FLOW (ID),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql
new file mode 100644
index 00000000..acfe4879
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-42-create-authenticator-config-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS `AUTHENTICATOR_CONFIG`
+(
+ `ID` Utf8 NOT NULL,
+ `ALIAS` Utf8,
+ `REALM_ID` Utf8,
+
+ INDEX idx_auth_config_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql
new file mode 100644
index 00000000..cf5fa858
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-44-create-required-action-provider-table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS `REQUIRED_ACTION_PROVIDER`
+(
+ `ID` Utf8 NOT NULL,
+ `ALIAS` Utf8,
+ `NAME` Utf8,
+ `REALM_ID` Utf8,
+ `ENABLED` Bool NOT NULL DEFAULT false,
+ `DEFAULT_ACTION` Bool NOT NULL DEFAULT false,
+ `PROVIDER_ID` Utf8,
+ `PRIORITY` Int32,
+
+ INDEX idx_req_act_prov_realm GLOBAL ON (`REALM_ID`),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql
new file mode 100644
index 00000000..5cfcf531
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-45-create-required-action-config-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS `REQUIRED_ACTION_CONFIG`
+(
+ `REQUIRED_ACTION_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+ PRIMARY KEY (`REQUIRED_ACTION_ID`, `NAME`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql
new file mode 100644
index 00000000..53c25a4f
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-46-create-authenticator-config-entry-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS `AUTHENTICATOR_CONFIG_ENTRY`
+(
+ `AUTHENTICATOR_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+ PRIMARY KEY (`AUTHENTICATOR_ID`, `NAME`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql
new file mode 100644
index 00000000..8bf697ca
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-47-create-component-table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS COMPONENT
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8,
+ `PARENT_ID` Utf8,
+ `PROVIDER_ID` Utf8,
+ `PROVIDER_TYPE` Utf8,
+ `REALM_ID` Utf8,
+ `SUB_TYPE` Utf8,
+
+ INDEX idx_component_realm GLOBAL ON (REALM_ID),
+ INDEX idx_component_provider_type GLOBAL ON (PROVIDER_TYPE),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql
new file mode 100644
index 00000000..a391c65d
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-00-48-create-component-config-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS COMPONENT_CONFIG
+(
+ `ID` Utf8 NOT NULL,
+ `COMPONENT_ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+
+ INDEX idx_compo_config_compo GLOBAL ON (COMPONENT_ID),
+-- FOREIGN KEY (COMPONENT_ID) REFERENCES COMPONENT (ID),
+ PRIMARY KEY (ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql
new file mode 100644
index 00000000..b2f7cb03
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-00-create-client-table.sql
@@ -0,0 +1,32 @@
+CREATE TABLE IF NOT EXISTS CLIENT
+(
+ `ID` Utf8 NOT NULL,
+ `ENABLED` Bool DEFAULT false,
+ `FULL_SCOPE_ALLOWED` Bool DEFAULT false,
+ `CLIENT_ID` Utf8,
+ `NOT_BEFORE` Int32,
+ `PUBLIC_CLIENT` Bool DEFAULT false,
+ `SECRET` Utf8,
+ `BASE_URL` Utf8,
+ `BEARER_ONLY` Bool DEFAULT false,
+ `MANAGEMENT_URL` Utf8,
+ `SURROGATE_AUTH_REQUIRED` Bool DEFAULT false,
+ `REALM_ID` Utf8,
+ `PROTOCOL` Utf8,
+ `NODE_REREG_TIMEOUT` Int32 DEFAULT 0,
+ `FRONTCHANNEL_LOGOUT` Bool DEFAULT false,
+ `CONSENT_REQUIRED` Bool DEFAULT false,
+ `NAME` Utf8,
+ `SERVICE_ACCOUNTS_ENABLED` Bool DEFAULT false,
+ `CLIENT_AUTHENTICATOR_TYPE` Utf8,
+ `ROOT_URL` Utf8,
+ `DESCRIPTION` Utf8,
+ `REGISTRATION_TOKEN` Utf8,
+ `STANDARD_FLOW_ENABLED` Bool DEFAULT true,
+ `IMPLICIT_FLOW_ENABLED` Bool DEFAULT false,
+ `DIRECT_ACCESS_GRANTS_ENABLED` Bool DEFAULT false,
+ `ALWAYS_DISPLAY_IN_CONSOLE` Bool DEFAULT false,
+
+ INDEX idx_client_id GLOBAL ASYNC ON (`CLIENT_ID`),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql
new file mode 100644
index 00000000..67837bac
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2025-12-20-01-01-create-event-entity-table.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS EVENT_ENTITY
+(
+ `ID` Utf8 NOT NULL,
+ `CLIENT_ID` Utf8,
+ `DETAILS_JSON` Utf8,
+ `ERROR` Utf8,
+ `IP_ADDRESS` Utf8,
+ `REALM_ID` Utf8,
+ `SESSION_ID` Utf8,
+ `EVENT_TIME` Int64,
+ `TYPE` Utf8,
+ `USER_ID` Utf8,
+ `DETAILS_JSON_LONG_VALUE` Text,
+
+ INDEX idx_event_time GLOBAL ON (`REALM_ID`, `EVENT_TIME`),
+ INDEX idx_event_entity_user_id_type GLOBAL ON (`USER_ID`, `TYPE`, `EVENT_TIME`),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql
new file mode 100644
index 00000000..fd757fa7
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-15-04-keycloak-role-table.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS KEYCLOAK_ROLE
+(
+ `ID` Utf8 NOT NULL,
+ `CLIENT_REALM_CONSTRAINT` Utf8,
+ `CLIENT_ROLE` Bool NOT NULL DEFAULT false,
+ `DESCRIPTION` Utf8,
+ `NAME` Utf8,
+ `REALM_ID` Utf8,
+ `CLIENT` Utf8,
+ `REALM` Utf8,
+
+ INDEX idx_keycloak_role_client GLOBAL ON (`CLIENT`),
+
+ INDEX fk_6vyqfe4cn4wlq8r6kt5vdsj5c GLOBAL ON (`REALM`),
+ INDEX `UK_J3RWUVD56ONTGSUHOGM184WW2-2` GLOBAL UNIQUE ON (`NAME`, `CLIENT_REALM_CONSTRAINT`),
+-- FOREIGN KEY (REALM) REFERENCES REALM (ID),
+ PRIMARY KEY (`ID`)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql
new file mode 100644
index 00000000..24294957
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-01-16-04-composite-role-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS COMPOSITE_ROLE
+(
+ `COMPOSITE` Utf8 NOT NULL,
+ `CHILD_ROLE` Utf8 NOT NULL,
+
+ INDEX idx_composite GLOBAL ON (COMPOSITE),
+ INDEX idx_composite_child GLOBAL ON (CHILD_ROLE),
+-- FOREIGN KEY (COMPOSITE) REFERENCES KEYCLOAK_ROLE (ID),
+-- FOREIGN KEY (CHILD_ROLE) REFERENCES KEYCLOAK_ROLE (ID),
+ PRIMARY KEY (COMPOSITE, CHILD_ROLE)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql
new file mode 100644
index 00000000..58d7af16
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-03-2-16-role-attrubute-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS ROLE_ATTRIBUTE
+(
+ `ID` Utf8 NOT NULL,
+ `ROLE_ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+
+ INDEX idx_role_attribute GLOBAL ON (ROLE_ID),
+-- FOREIGN KEY (ROLE_ID) REFERENCES KEYCLOAK_ROLE (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql
new file mode 100644
index 00000000..9a805853
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-06-00-create-migration-model-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS MIGRATION_MODEL
+(
+ `ID` Utf8 NOT NULL,
+ `VERSION` Utf8,
+ `UPDATE_TIME` Int64 NOT NULL DEFAULT 0,
+
+ INDEX uk_migration_version GLOBAL UNIQUE ON (`VERSION`),
+ INDEX uk_migration_update_time GLOBAL UNIQUE ON (`UPDATE_TIME`),
+ INDEX idx_update_time GLOBAL ON (`UPDATE_TIME`),
+ PRIMARY KEY (`ID`)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql
new file mode 100644
index 00000000..db936ced
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-24-create-client-attributes-table.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS CLIENT_ATTRIBUTES
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+
+ INDEX idx_client_att_by_name_value GLOBAL ON (`NAME`, `VALUE`),
+-- INDEX idx_client_att_by_name_value GLOBAL ON (`NAME`, SUBSTRING(`VALUE`, 1, 255)),
+-- not implemented in ydb...
+-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID),
+ PRIMARY KEY (CLIENT_ID, NAME)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql
new file mode 100644
index 00000000..f4ab0d82
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-34-create-redirect-uris-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS REDIRECT_URIS
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `VALUE` Utf8 NOT NULL,
+
+ INDEX idx_redir_uri_client GLOBAL ON (CLIENT_ID),
+-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID),
+ PRIMARY KEY (CLIENT_ID, VALUE)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql
new file mode 100644
index 00000000..28c28301
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-35-create-protocol-mappers-table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS PROTOCOL_MAPPER
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `PROTOCOL` Utf8 NOT NULL,
+ `PROTOCOL_MAPPER_NAME` Utf8 NOT NULL,
+ `CLIENT_ID` Utf8,
+ `CLIENT_SCOPE_ID` Utf8,
+
+ INDEX idx_protocol_mapper_client GLOBAL ON (CLIENT_ID),
+ INDEX idx_clscope_protmap GLOBAL ON (CLIENT_SCOPE_ID),
+-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID),
+-- FOREIGN KEY (CLIENT_SCOPE_ID) REFERENCES CLIENT_SCOPE (ID),
+ PRIMARY KEY (ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql
new file mode 100644
index 00000000..fdf8a131
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-06-21-38-create-protocol-mapper-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS PROTOCOL_MAPPER_CONFIG
+(
+ `PROTOCOL_MAPPER_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (PROTOCOL_MAPPER_ID) REFERENCES PROTOCOL_MAPPER (ID),
+ PRIMARY KEY (PROTOCOL_MAPPER_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql
new file mode 100644
index 00000000..2f454fb4
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-02-06-create-scope-mapping-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS SCOPE_MAPPING
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `ROLE_ID` Utf8 NOT NULL,
+
+ INDEX idx_scope_mapping_role GLOBAL ON (ROLE_ID),
+-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID),
+ PRIMARY KEY (CLIENT_ID, ROLE_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql
new file mode 100644
index 00000000..5f0ca7b9
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-03-53-create-web-origins-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS WEB_ORIGINS
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `VALUE` Utf8 NOT NULL,
+
+ INDEX idx_web_orig_client GLOBAL ON (CLIENT_ID),
+-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID),
+ PRIMARY KEY (CLIENT_ID, VALUE)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql
new file mode 100644
index 00000000..8046c894
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-38-create-client-scope-table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS CLIENT_SCOPE
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8,
+ `REALM_ID` Utf8,
+ `DESCRIPTION` Utf8,
+ `PROTOCOL` Utf8,
+
+-- in pg
+-- INDEX idx_realm_clscope GLOBAL ON (REALM_ID),
+-- CONSTRAINT `UK_CLI_SCOPE` GLOBAL UNIQUE ON (REALM_ID, NAME),
+ INDEX idx_realm_clscope GLOBAL UNIQUE ON (REALM_ID, NAME),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql
new file mode 100644
index 00000000..d0aa3e57
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-50-create-client-scope-attributes-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_ATTRIBUTES
+(
+ `SCOPE_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+ INDEX idx_clscope_attrs GLOBAL ON (SCOPE_ID),
+-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID),
+ PRIMARY KEY (SCOPE_ID, NAME)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql
new file mode 100644
index 00000000..05c8307a
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-51-create-client-scope-client-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_CLIENT
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `SCOPE_ID` Utf8 NOT NULL,
+ `DEFAULT_SCOPE` Bool NOT NULL DEFAULT false,
+
+ INDEX idx_clscope_cl GLOBAL ON (CLIENT_ID),
+ INDEX idx_cl_clscope GLOBAL ON (SCOPE_ID),
+ PRIMARY KEY (CLIENT_ID, SCOPE_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql
new file mode 100644
index 00000000..fabf479a
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-15-52-create-client-scope-role-mapping-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS CLIENT_SCOPE_ROLE_MAPPING
+(
+ `SCOPE_ID` Utf8 NOT NULL,
+ `ROLE_ID` Utf8 NOT NULL,
+
+ INDEX idx_clscope_role GLOBAL ON (SCOPE_ID),
+ INDEX idx_role_clscope GLOBAL ON (ROLE_ID),
+-- FOREIGN KEY (SCOPE_ID) REFERENCES CLIENT_SCOPE (ID),
+ PRIMARY KEY (SCOPE_ID, ROLE_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql
new file mode 100644
index 00000000..e210c270
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-00-create-user-entity-table.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS USER_ENTITY
+(
+ `ID` Utf8 NOT NULL,
+ `EMAIL` Utf8,
+ `EMAIL_CONSTRAINT` Utf8,
+ `EMAIL_VERIFIED` Bool NOT NULL DEFAULT false,
+ `ENABLED` Bool NOT NULL DEFAULT false,
+ `FEDERATION_LINK` Utf8,
+ `FIRST_NAME` Utf8,
+ `LAST_NAME` Utf8,
+ `REALM_ID` Utf8,
+ `USERNAME` Utf8,
+ `CREATED_TIMESTAMP` Int64,
+ `SERVICE_ACCOUNT_CLIENT_LINK` Utf8,
+ `NOT_BEFORE` Int32 NOT NULL DEFAULT 0,
+
+ INDEX idx_user_email GLOBAL ON (EMAIL),
+ INDEX idx_user_service_account GLOBAL ASYNC ON (REALM_ID, SERVICE_ACCOUNT_CLIENT_LINK),
+-- CONSTRAINT `UK_DYKN684SL8UP1CRFEI6ECKHD7` GLOBAL UNIQUE ON (REALM_ID, EMAIL_CONSTRAINT),
+-- CONSTRAINT `UK_RU8TT6T700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (REALM_ID, USERNAME),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql
new file mode 100644
index 00000000..65485a75
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-02-create-user-role-mapping-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS USER_ROLE_MAPPING
+(
+ `ROLE_ID` Utf8 NOT NULL,
+ `USER_ID` Utf8 NOT NULL,
+
+ INDEX idx_user_role_mapping GLOBAL ON (USER_ID),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (ROLE_ID, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql
new file mode 100644
index 00000000..92e27bc2
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-05-create-identity-provider-table.sql
@@ -0,0 +1,25 @@
+CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER
+(
+ `INTERNAL_ID` Utf8 NOT NULL,
+ `ENABLED` Bool NOT NULL DEFAULT false,
+ `PROVIDER_ALIAS` Utf8,
+ `PROVIDER_ID` Utf8,
+ `STORE_TOKEN` Bool NOT NULL DEFAULT false,
+ `AUTHENTICATE_BY_DEFAULT` Bool NOT NULL DEFAULT false,
+ `REALM_ID` Utf8,
+ `ADD_TOKEN_ROLE` Bool NOT NULL DEFAULT true,
+ `TRUST_EMAIL` Bool NOT NULL DEFAULT false,
+ `FIRST_BROKER_LOGIN_FLOW_ID` Utf8,
+ `POST_BROKER_LOGIN_FLOW_ID` Utf8,
+ `PROVIDER_DISPLAY_NAME` Utf8,
+ `LINK_ONLY` Bool NOT NULL DEFAULT false,
+ `ORGANIZATION_ID` Utf8,
+ `HIDE_ON_LOGIN` Bool DEFAULT false,
+
+ INDEX idx_ident_prov_realm GLOBAL ON (REALM_ID),
+ INDEX idx_idp_realm_org GLOBAL ON (REALM_ID, ORGANIZATION_ID),
+ INDEX idx_idp_for_login GLOBAL ON (REALM_ID, ENABLED, LINK_ONLY, HIDE_ON_LOGIN, ORGANIZATION_ID),
+-- CONSTRAINT `UK_2DAELWNIBJI49AVXSRTUF6XJ33` GLOBAL UNIQUE ON (PROVIDER_ALIAS, REALM_ID),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (INTERNAL_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql
new file mode 100644
index 00000000..c1a9b52c
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-06-create-identity-provider-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_CONFIG
+(
+ `IDENTITY_PROVIDER_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (IDENTITY_PROVIDER_ID) REFERENCES IDENTITY_PROVIDER (INTERNAL_ID),
+ PRIMARY KEY (IDENTITY_PROVIDER_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql
new file mode 100644
index 00000000..12260bd6
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-07-create-identity-provider-mapper-table.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS IDENTITY_PROVIDER_MAPPER
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `IDP_ALIAS` Utf8 NOT NULL,
+ `IDP_MAPPER_NAME` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+
+ INDEX idx_id_prov_mapp_realm GLOBAL ON (REALM_ID),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql
new file mode 100644
index 00000000..9ede15f6
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-09-create-credential-table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS CREDENTIAL
+(
+ `ID` Utf8 NOT NULL,
+ `SALT` String,
+ `TYPE` Utf8,
+ `USER_ID` Utf8,
+ `CREATED_DATE` Int64,
+ `USER_LABEL` Utf8,
+ `SECRET_DATA` Utf8,
+ `CREDENTIAL_DATA` Utf8,
+ `PRIORITY` Int32,
+ `VERSION` Int32 DEFAULT 0,
+
+ INDEX idx_user_credential GLOBAL ON (USER_ID),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql
new file mode 100644
index 00000000..de0c8ecc
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-11-create-user-attribute-table.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS USER_ATTRIBUTE
+(
+-- maybe sybase-needs-something-here is not needed
+-- because it was added in other changelog
+ `ID` Utf8 NOT NULL DEFAULT "sybase-needs-something-here",
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `USER_ID` Utf8 NOT NULL,
+ `LONG_VALUE_HASH` String,
+ `LONG_VALUE_HASH_LOWER_CASE` String,
+ `LONG_VALUE` Utf8,
+
+ INDEX idx_user_attribute GLOBAL ON (USER_ID),
+ INDEX idx_user_attribute_name GLOBAL ON (NAME, VALUE),
+ INDEX user_attr_long_values GLOBAL ON (LONG_VALUE_HASH, NAME),
+ INDEX user_attr_long_values_lower_case GLOBAL ON (LONG_VALUE_HASH_LOWER_CASE, NAME),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql
new file mode 100644
index 00000000..fdae1321
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-15-create-revoked-token-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS REVOKED_TOKEN
+(
+ `ID` Utf8 NOT NULL,
+ `EXPIRE` Int64 NOT NULL,
+
+ INDEX idx_rev_token_on_expire GLOBAL ON (EXPIRE),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql
new file mode 100644
index 00000000..e535262e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-19-create-client-auth-flow-bindings-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS CLIENT_AUTH_FLOW_BINDINGS
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `FLOW_ID` Utf8,
+ `BINDING_NAME` Utf8 NOT NULL,
+
+ PRIMARY KEY (CLIENT_ID, BINDING_NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql
new file mode 100644
index 00000000..7e058850
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-21-create-client-node-registrations-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS CLIENT_NODE_REGISTRATIONS
+(
+ `CLIENT_ID` Utf8 NOT NULL,
+ `VALUE` Int32,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (CLIENT_ID) REFERENCES CLIENT (ID),
+ PRIMARY KEY (CLIENT_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql
new file mode 100644
index 00000000..74c0cff4
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-24-create-user-required-action-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS USER_REQUIRED_ACTION
+(
+ `USER_ID` Utf8 NOT NULL,
+ `REQUIRED_ACTION` Utf8 NOT NULL DEFAULT " ",
+
+ INDEX idx_user_reqactions GLOBAL ON (USER_ID),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (REQUIRED_ACTION, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql
new file mode 100644
index 00000000..a5f8eafa
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-26-create-offline-client-session-table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS OFFLINE_CLIENT_SESSION
+(
+ `USER_SESSION_ID` Utf8 NOT NULL,
+ `CLIENT_ID` Utf8 NOT NULL,
+ `OFFLINE_FLAG` Utf8 NOT NULL,
+ `TIMESTAMP` Int32,
+ `DATA` Utf8,
+ `CLIENT_STORAGE_PROVIDER` Utf8 NOT NULL DEFAULT "local",
+ `EXTERNAL_CLIENT_ID` Utf8 NOT NULL DEFAULT "local",
+ `VERSION` Int32 DEFAULT 0,
+
+ INDEX idx_offline_css_by_client GLOBAL ON (CLIENT_ID, OFFLINE_FLAG),
+ INDEX idx_offline_css_by_client_storage_provider GLOBAL ON (CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID, OFFLINE_FLAG),
+ PRIMARY KEY (USER_SESSION_ID, CLIENT_ID, CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID, OFFLINE_FLAG)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql
new file mode 100644
index 00000000..a3682ef6
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-27-create-offline-user-session-table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS OFFLINE_USER_SESSION
+(
+ `USER_SESSION_ID` Utf8 NOT NULL,
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `CREATED_ON` Int32 NOT NULL,
+ `OFFLINE_FLAG` Utf8 NOT NULL,
+ `DATA` Utf8,
+ `LAST_SESSION_REFRESH` Int32 NOT NULL DEFAULT 0,
+ `BROKER_SESSION_ID` Utf8,
+ `VERSION` Int32 DEFAULT 0,
+
+ INDEX idx_offline_uss_by_user GLOBAL ON (USER_ID, REALM_ID, OFFLINE_FLAG),
+ INDEX idx_offline_uss_by_last_session_refresh GLOBAL ON (REALM_ID, OFFLINE_FLAG, LAST_SESSION_REFRESH),
+ INDEX idx_offline_uss_by_broker_session_id GLOBAL ON (BROKER_SESSION_ID, REALM_ID),
+ PRIMARY KEY (USER_SESSION_ID, OFFLINE_FLAG)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql
new file mode 100644
index 00000000..3f0d0be0
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-37-create-user-group-membership-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS USER_GROUP_MEMBERSHIP
+(
+ `GROUP_ID` Utf8 NOT NULL,
+ `USER_ID` Utf8 NOT NULL,
+ `MEMBERSHIP_TYPE` Utf8 NOT NULL,
+
+ INDEX idx_user_group_mapping GLOBAL ON (USER_ID),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (GROUP_ID, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql
new file mode 100644
index 00000000..59fcdcf2
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-40-create-keycloak-group-table.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS KEYCLOAK_GROUP
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8,
+ `PARENT_GROUP` Utf8 NOT NULL,
+ `REALM_ID` Utf8,
+ `TYPE` Int32 NOT NULL DEFAULT 0,
+ `DESCRIPTION` Utf8,
+
+-- CONSTRAINT `SIBLING_NAMES` GLOBAL UNIQUE ON (REALM_ID, PARENT_GROUP, NAME),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql
new file mode 100644
index 00000000..70e97eb8
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-53-create-org-table.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS ORG
+(
+ `ID` Utf8 NOT NULL,
+ `ENABLED` Bool NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `GROUP_ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `DESCRIPTION` Utf8,
+ `ALIAS` Utf8 NOT NULL,
+ `REDIRECT_URL` Utf8,
+
+-- CONSTRAINT `UK_ORG_GROUP` GLOBAL UNIQUE ON (GROUP_ID),
+-- CONSTRAINT `UK_ORG_NAME` GLOBAL UNIQUE ON (REALM_ID, NAME),
+-- CONSTRAINT `UK_ORG_ALIAS` GLOBAL UNIQUE ON (REALM_ID, ALIAS),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql
new file mode 100644
index 00000000..af703798
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-16-55-create-org-domain-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS ORG_DOMAIN
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `VERIFIED` Bool NOT NULL,
+ `ORG_ID` Utf8 NOT NULL,
+
+ INDEX idx_org_domain_org_id GLOBAL ON (ORG_ID),
+ PRIMARY KEY (ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql
new file mode 100644
index 00000000..57fa7fd8
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-04-create-user-consent-table.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS USER_CONSENT
+(
+ `ID` Utf8 NOT NULL,
+ `CLIENT_ID` Utf8,
+ `USER_ID` Utf8 NOT NULL,
+ `CREATED_DATE` Int64,
+ `LAST_UPDATED_DATE` Int64,
+ `CLIENT_STORAGE_PROVIDER` Utf8,
+ `EXTERNAL_CLIENT_ID` Utf8,
+
+ INDEX idx_user_consent GLOBAL ON (USER_ID),
+-- CONSTRAINT `UK_LOCAL_CONSENT` GLOBAL UNIQUE ON (CLIENT_ID, USER_ID),
+-- CONSTRAINT `UK_EXTERNAL_CONSENT` GLOBAL UNIQUE ON (CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID, USER_ID),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql
new file mode 100644
index 00000000..8e415fb8
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-05-create-user-consent-client-scope-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS USER_CONSENT_CLIENT_SCOPE
+(
+ `USER_CONSENT_ID` Utf8 NOT NULL,
+ `SCOPE_ID` Utf8 NOT NULL,
+
+ INDEX idx_usconsent_clscope GLOBAL ON (USER_CONSENT_ID),
+ INDEX idx_usconsent_scope_id GLOBAL ON (SCOPE_ID),
+-- FOREIGN KEY (USER_CONSENT_ID) REFERENCES USER_CONSENT (ID),
+ PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql
new file mode 100644
index 00000000..b7de43cf
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-08-create-federated-identity-table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS FEDERATED_IDENTITY
+(
+ `IDENTITY_PROVIDER` Utf8 NOT NULL,
+ `REALM_ID` Utf8,
+ `FEDERATED_USER_ID` Utf8,
+ `FEDERATED_USERNAME` Utf8,
+ `TOKEN` Utf8,
+ `USER_ID` Utf8 NOT NULL,
+
+ INDEX idx_fedidentity_user GLOBAL ON (USER_ID),
+ INDEX idx_fedidentity_feduser GLOBAL ON (FEDERATED_USER_ID),
+-- FOREIGN KEY (USER_ID) REFERENCES USER_ENTITY (ID),
+ PRIMARY KEY (IDENTITY_PROVIDER, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql
new file mode 100644
index 00000000..6d9f2725
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-09-create-fed-user-consent-table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS FED_USER_CONSENT
+(
+ `ID` Utf8 NOT NULL,
+ `CLIENT_ID` Utf8,
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+ `CREATED_DATE` Int64,
+ `LAST_UPDATED_DATE` Int64,
+ `CLIENT_STORAGE_PROVIDER` Utf8,
+ `EXTERNAL_CLIENT_ID` Utf8,
+
+ INDEX idx_fu_consent_ru GLOBAL ON (REALM_ID, USER_ID),
+ INDEX idx_fu_cnsnt_ext GLOBAL ON (USER_ID, CLIENT_STORAGE_PROVIDER, EXTERNAL_CLIENT_ID),
+ INDEX idx_fu_consent GLOBAL ON (USER_ID, CLIENT_ID),
+ PRIMARY KEY (ID)
+ );
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql
new file mode 100644
index 00000000..b1463d1f
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-10-create-fed-user-consent-cl-scope-table.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS FED_USER_CONSENT_CL_SCOPE
+(
+ `USER_CONSENT_ID` Utf8 NOT NULL,
+ `SCOPE_ID` Utf8 NOT NULL,
+
+ PRIMARY KEY (USER_CONSENT_ID, SCOPE_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql
new file mode 100644
index 00000000..5e11a567
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-11-create-fed-user-credential-table.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS FED_USER_CREDENTIAL
+(
+ `ID` Utf8 NOT NULL,
+ `SALT` String,
+ `TYPE` Utf8,
+ `CREATED_DATE` Int64,
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+ `USER_LABEL` Utf8,
+ `SECRET_DATA` Utf8,
+ `CREDENTIAL_DATA` Utf8,
+ `PRIORITY` Int32,
+
+ INDEX idx_fu_credential GLOBAL ON (USER_ID, TYPE),
+ INDEX idx_fu_credential_ru GLOBAL ON (REALM_ID, USER_ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql
new file mode 100644
index 00000000..66b65f47
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-group-membership-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS FED_USER_GROUP_MEMBERSHIP
+(
+ `GROUP_ID` Utf8 NOT NULL,
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+
+ INDEX idx_fu_group_membership GLOBAL ON (USER_ID, GROUP_ID),
+ INDEX idx_fu_group_membership_ru GLOBAL ON (REALM_ID, USER_ID),
+ PRIMARY KEY (GROUP_ID, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql
new file mode 100644
index 00000000..719c676b
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-12-create-fed-user-required-action-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS FED_USER_REQUIRED_ACTION
+(
+ `REQUIRED_ACTION` Utf8 NOT NULL DEFAULT " ",
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+
+ INDEX idx_fu_required_action GLOBAL ON (USER_ID, REQUIRED_ACTION),
+ INDEX idx_fu_required_action_ru GLOBAL ON (REALM_ID, USER_ID),
+ PRIMARY KEY (REQUIRED_ACTION, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql
new file mode 100644
index 00000000..d81d4a23
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-13-create-fed-user-role-mapping-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS FED_USER_ROLE_MAPPING
+(
+ `ROLE_ID` Utf8 NOT NULL,
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+
+ INDEX idx_fu_role_mapping GLOBAL ON (USER_ID, ROLE_ID),
+ INDEX idx_fu_role_mapping_ru GLOBAL ON (REALM_ID, USER_ID),
+ PRIMARY KEY (ROLE_ID, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql
new file mode 100644
index 00000000..8ff82ef6
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-14-create-federated-user-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS FEDERATED_USER
+(
+ `ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+ `REALM_ID` Utf8 NOT NULL,
+
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql
new file mode 100644
index 00000000..4e90ef13
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-15-create-broker-link-table.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS BROKER_LINK
+(
+ `IDENTITY_PROVIDER` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+ `REALM_ID` Utf8 NOT NULL,
+ `BROKER_USER_ID` Utf8,
+ `BROKER_USERNAME` Utf8,
+ `TOKEN` Utf8,
+ `USER_ID` Utf8 NOT NULL,
+
+ PRIMARY KEY (IDENTITY_PROVIDER, USER_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql
new file mode 100644
index 00000000..d98a215f
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-16-create-fed-user-attribute-table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS FED_USER_ATTRIBUTE
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `USER_ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `STORAGE_PROVIDER_ID` Utf8,
+ `VALUE` Utf8,
+ `LONG_VALUE_HASH` String,
+ `LONG_VALUE_HASH_LOWER_CASE` String,
+ `LONG_VALUE` Utf8,
+
+ INDEX idx_fu_attribute GLOBAL ON (USER_ID, REALM_ID, NAME),
+ INDEX fed_user_attr_long_values GLOBAL ON (LONG_VALUE_HASH, NAME),
+ INDEX fed_user_attr_long_values_lower_case GLOBAL ON (LONG_VALUE_HASH_LOWER_CASE, NAME),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql
new file mode 100644
index 00000000..f9a82f37
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-17-create-group-attribute-table.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS GROUP_ATTRIBUTE
+(
+ `ID` Utf8 NOT NULL DEFAULT "sybase-needs-something-here",
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `GROUP_ID` Utf8 NOT NULL,
+
+ INDEX idx_group_attr_group GLOBAL ON (GROUP_ID),
+ INDEX idx_group_att_by_name_value GLOBAL ON (NAME, VALUE),
+-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql
new file mode 100644
index 00000000..3c7e966e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-07-17-18-create-group-role-mapping-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS GROUP_ROLE_MAPPING
+(
+ `ROLE_ID` Utf8 NOT NULL,
+ `GROUP_ID` Utf8 NOT NULL,
+
+ INDEX idx_group_role_mapp_group GLOBAL ON (GROUP_ID),
+-- FOREIGN KEY (GROUP_ID) REFERENCES KEYCLOAK_GROUP (ID),
+ PRIMARY KEY (ROLE_ID, GROUP_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql
new file mode 100644
index 00000000..8d4d9375
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-53-create-resource-server-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_SERVER
+(
+ `ID` Utf8 NOT NULL,
+ `ALLOW_RS_REMOTE_MGMT` Bool NOT NULL DEFAULT false,
+ `POLICY_ENFORCE_MODE` Int16 NOT NULL,
+ `DECISION_STRATEGY` Int16 NOT NULL DEFAULT 1,
+
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql
new file mode 100644
index 00000000..e5e27023
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-54-create-resource-server-policy-table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_POLICY
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `DESCRIPTION` Utf8,
+ `TYPE` Utf8 NOT NULL,
+ `DECISION_STRATEGY` Int16,
+ `LOGIC` Int16,
+ `RESOURCE_SERVER_ID` Utf8 NOT NULL,
+ `OWNER` Utf8,
+
+ INDEX idx_res_serv_pol_res_serv GLOBAL ON (RESOURCE_SERVER_ID),
+-- todo maybe add unique index `ON (NAME, RESOURCE_SERVER_ID)`
+-- CONSTRAINT `UK_FRSRPT700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (NAME, RESOURCE_SERVER_ID),
+-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql
new file mode 100644
index 00000000..60acb552
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-56-create-resource-server-resource-table.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_RESOURCE
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `TYPE` Utf8,
+ `ICON_URI` Utf8,
+ `OWNER` Utf8 NOT NULL,
+ `RESOURCE_SERVER_ID` Utf8 NOT NULL,
+ `OWNER_MANAGED_ACCESS` Bool NOT NULL DEFAULT false,
+ `DISPLAY_NAME` Utf8,
+
+ INDEX idx_res_srv_res_res_srv GLOBAL ON (RESOURCE_SERVER_ID),
+-- TODO: maybe create UNIQUE index `ON (NAME, OWNER, RESOURCE_SERVER_ID),`
+-- CONSTRAINT `UK_FRSR6T700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (NAME, OWNER, RESOURCE_SERVER_ID),
+-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql
new file mode 100644
index 00000000..07863a1e
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-57-create-resource-attribute-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_ATTRIBUTE
+(
+ `ID` Utf8 NOT NULL DEFAULT "sybase-needs-something-here",
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `RESOURCE_ID` Utf8 NOT NULL,
+
+-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql
new file mode 100644
index 00000000..7b594e05
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-58-create-resource-policy-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_POLICY
+(
+ `RESOURCE_ID` Utf8 NOT NULL,
+ `POLICY_ID` Utf8 NOT NULL,
+
+ INDEX idx_res_policy_policy GLOBAL ON (POLICY_ID),
+-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID),
+-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID),
+ PRIMARY KEY (RESOURCE_ID, POLICY_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql
new file mode 100644
index 00000000..b3176409
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-19-59-create-resource-server-scope-table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_SCOPE
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `ICON_URI` Utf8,
+ `RESOURCE_SERVER_ID` Utf8 NOT NULL,
+ `DISPLAY_NAME` Utf8,
+
+ INDEX idx_res_srv_scope_res_srv GLOBAL ON (RESOURCE_SERVER_ID),
+-- TODO: maybe create UNIQUE index `ON (NAME, RESOURCE_SERVER_ID)`
+-- CONSTRAINT `UK_FRSRST700S9V50BU18WS5HA6` GLOBAL UNIQUE ON (NAME, RESOURCE_SERVER_ID),
+-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql
new file mode 100644
index 00000000..ce6f9b30
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-00-create-resource-scope-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_SCOPE
+(
+ `RESOURCE_ID` Utf8 NOT NULL,
+ `SCOPE_ID` Utf8 NOT NULL,
+
+ INDEX idx_res_scope_scope GLOBAL ON (SCOPE_ID),
+-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID),
+-- FOREIGN KEY (SCOPE_ID) REFERENCES RESOURCE_SERVER_SCOPE (ID),
+ PRIMARY KEY (RESOURCE_ID, SCOPE_ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql
new file mode 100644
index 00000000..93964d43
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-01-create-resource-server-perm-ticket-table.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_SERVER_PERM_TICKET
+(
+ `ID` Utf8 NOT NULL,
+ `OWNER` Utf8 NOT NULL,
+ `REQUESTER` Utf8 NOT NULL,
+ `CREATED_TIMESTAMP` Int64 NOT NULL,
+ `GRANTED_TIMESTAMP` Int64,
+ `RESOURCE_ID` Utf8 NOT NULL,
+ `SCOPE_ID` Utf8,
+ `RESOURCE_SERVER_ID` Utf8 NOT NULL,
+ `POLICY_ID` Utf8,
+
+ INDEX idx_perm_ticket_requester GLOBAL ON (REQUESTER),
+ INDEX idx_perm_ticket_owner GLOBAL ON (OWNER),
+-- TODO: maybe create UNIQUE index `ON (OWNER, REQUESTER, RESOURCE_SERVER_ID, RESOURCE_ID, SCOPE_ID),`
+-- CONSTRAINT `UK_FRSR6T700S9V50BU18WS5PMT` GLOBAL UNIQUE ON (OWNER, REQUESTER, RESOURCE_SERVER_ID, RESOURCE_ID, SCOPE_ID),
+-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID),
+-- FOREIGN KEY (SCOPE_ID) REFERENCES RESOURCE_SERVER_SCOPE (ID),
+-- FOREIGN KEY (RESOURCE_SERVER_ID) REFERENCES RESOURCE_SERVER (ID),
+-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql
new file mode 100644
index 00000000..1167b7b7
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-08-20-02-create-resource-uris-table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS RESOURCE_URIS
+(
+ `RESOURCE_ID` Utf8 NOT NULL,
+ `VALUE` Utf8 NOT NULL,
+
+-- FOREIGN KEY (RESOURCE_ID) REFERENCES RESOURCE_SERVER_RESOURCE (ID),
+ PRIMARY KEY (RESOURCE_ID, VALUE)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql
new file mode 100644
index 00000000..27866df4
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-13-45-create-policy-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS POLICY_CONFIG
+(
+ `POLICY_ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `VALUE` Utf8,
+
+-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID),
+ PRIMARY KEY (POLICY_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql
new file mode 100644
index 00000000..21f71a52
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-18-create-idp-mapper-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS IDP_MAPPER_CONFIG
+(
+ `IDP_MAPPER_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (IDP_MAPPER_ID) REFERENCES IDENTITY_PROVIDER_MAPPER (ID),
+ PRIMARY KEY (IDP_MAPPER_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql
new file mode 100644
index 00000000..a8b17280
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-21-create-client-initial-access-table.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS CLIENT_INITIAL_ACCESS
+(
+ `ID` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+ `TIMESTAMP` Int32,
+ `EXPIRATION` Int32,
+ `COUNT` Int32,
+ `REMAINING_COUNT` Int32,
+
+ INDEX idx_client_init_acc_realm GLOBAL ON (REALM_ID),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql
new file mode 100644
index 00000000..ca8b018f
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-23-create-user-federation-provider-table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS USER_FEDERATION_PROVIDER
+(
+ `ID` Utf8 NOT NULL,
+ `CHANGED_SYNC_PERIOD` Int32,
+ `DISPLAY_NAME` Utf8,
+ `FULL_SYNC_PERIOD` Int32,
+ `LAST_SYNC` Int32,
+ `PRIORITY` Int32,
+ `PROVIDER_NAME` Utf8,
+ `REALM_ID` Utf8,
+
+ INDEX idx_usr_fed_prv_realm GLOBAL ON (REALM_ID),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql
new file mode 100644
index 00000000..ae7a5d95
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-24-create-user-federation-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS USER_FEDERATION_CONFIG
+(
+ `USER_FEDERATION_PROVIDER_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (USER_FEDERATION_PROVIDER_ID) REFERENCES USER_FEDERATION_PROVIDER (ID),
+ PRIMARY KEY (USER_FEDERATION_PROVIDER_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql
new file mode 100644
index 00000000..bd167b1a
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-25-create-user-federation-mapper-table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER
+(
+ `ID` Utf8 NOT NULL,
+ `NAME` Utf8 NOT NULL,
+ `FEDERATION_PROVIDER_ID` Utf8 NOT NULL,
+ `FEDERATION_MAPPER_TYPE` Utf8 NOT NULL,
+ `REALM_ID` Utf8 NOT NULL,
+
+ INDEX idx_usr_fed_map_fed_prv GLOBAL ON (FEDERATION_PROVIDER_ID),
+ INDEX idx_usr_fed_map_realm GLOBAL ON (REALM_ID),
+-- FOREIGN KEY (FEDERATION_PROVIDER_ID) REFERENCES USER_FEDERATION_PROVIDER (ID),
+-- FOREIGN KEY (REALM_ID) REFERENCES REALM (ID),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql
new file mode 100644
index 00000000..ae243527
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-02-15-15-29-create-user-federation-mapper-config-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS USER_FEDERATION_MAPPER_CONFIG
+(
+ `USER_FEDERATION_MAPPER_ID` Utf8 NOT NULL,
+ `VALUE` Utf8,
+ `NAME` Utf8 NOT NULL,
+
+-- FOREIGN KEY (USER_FEDERATION_MAPPER_ID) REFERENCES USER_FEDERATION_MAPPER (ID),
+ PRIMARY KEY (USER_FEDERATION_MAPPER_ID, NAME)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql
new file mode 100644
index 00000000..40e29ab3
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-03-06-00-21-create-admin-event-entity-table.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS ADMIN_EVENT_ENTITY
+(
+ `ID` Utf8 NOT NULL,
+ `ADMIN_EVENT_TIME` Int64,
+ `REALM_ID` Utf8,
+ `OPERATION_TYPE` Utf8,
+ `AUTH_REALM_ID` Utf8,
+ `AUTH_CLIENT_ID` Utf8,
+ `AUTH_USER_ID` Utf8,
+ `IP_ADDRESS` Utf8,
+ `RESOURCE_PATH` Utf8,
+ `REPRESENTATION` Utf8,
+ `ERROR` Utf8,
+ `RESOURCE_TYPE` Utf8,
+ `DETAILS_JSON` Utf8,
+
+ INDEX idx_admin_event_time GLOBAL ON (REALM_ID, ADMIN_EVENT_TIME),
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql
new file mode 100644
index 00000000..ae303452
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-01-create-associated-policy-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS ASSOCIATED_POLICY
+(
+ `POLICY_ID` Utf8 NOT NULL,
+ `ASSOCIATED_POLICY_ID` Utf8 NOT NULL,
+
+ INDEX idx_assoc_pol_assoc_pol_id GLOBAL ON (ASSOCIATED_POLICY_ID),
+-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID),
+-- FOREIGN KEY (ASSOCIATED_POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID),
+ PRIMARY KEY (POLICY_ID, ASSOCIATED_POLICY_ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql
new file mode 100644
index 00000000..10b2d834
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/changesets/2026-05-20-02-create-scope-policy-table.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS SCOPE_POLICY
+(
+ `SCOPE_ID` Utf8 NOT NULL,
+ `POLICY_ID` Utf8 NOT NULL,
+
+ INDEX idx_scope_policy_policy GLOBAL ON (POLICY_ID),
+-- FOREIGN KEY (SCOPE_ID) REFERENCES RESOURCE_SERVER_SCOPE (ID),
+-- FOREIGN KEY (POLICY_ID) REFERENCES RESOURCE_SERVER_POLICY (ID),
+ PRIMARY KEY (SCOPE_ID, POLICY_ID)
+);
diff --git a/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml b/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml
new file mode 100644
index 00000000..0cca7e61
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/main/resources/ydb/db.changelog-master.xml
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt
new file mode 100644
index 00000000..9ee31df9
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbEntityManagerProxyTest.kt
@@ -0,0 +1,72 @@
+package tech.ydb.keycloak.connection
+
+import jakarta.persistence.EntityManager
+import jakarta.ws.rs.WebApplicationException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil.ydbRetryableException
+
+class YdbEntityManagerProxyTest {
+
+ @Test
+ fun retryableErrorFromDelegateIsMappedTo503() {
+ val exception = ydbRetryableException("transaction conflict")
+ val em = mock()
+
+ whenever(em.flush()).thenAnswer { throw exception }
+ val proxy = YdbEntityManagerProxy.create(em)
+
+ val ex = assertThrows(WebApplicationException::class.java) { proxy.flush() }
+
+ assertEquals(503, ex.response.status)
+ assertEquals("application", ex.response.mediaType?.type)
+ assertEquals("json", ex.response.mediaType?.subtype)
+ assertSame(exception, ex.cause)
+ val body = ex.response.entity as String
+ assertTrue(body.contains("\"error\":\"ydb_retryable\""))
+ assertTrue(body.contains("Transaction aborted due to contention, please retry"))
+ }
+
+ @Test
+ fun retryableErrorInCauseChainIsMappedTo503() {
+ val exception = ydbRetryableException()
+ val em = mock()
+
+ whenever(em.flush()).thenThrow(RuntimeException("wrapper", exception))
+
+ val proxy = YdbEntityManagerProxy.create(em)
+
+ val ex = assertThrows(WebApplicationException::class.java) { proxy.flush() }
+
+ assertEquals(503, ex.response.status)
+ assertSame(exception, ex.cause?.cause)
+ }
+
+ @Test
+ fun nonRetryableErrorIsRethrown() {
+ val exception = IllegalStateException("db error")
+ val em = mock()
+ whenever(em.flush()).thenThrow(exception)
+
+ val proxy = YdbEntityManagerProxy.create(em)
+
+ val ex = assertThrows(IllegalStateException::class.java) { proxy.flush() }
+
+ assertSame(exception, ex)
+ }
+
+ @Test
+ fun successfulInvocationIsDelegated() {
+ val em = mock()
+ whenever(em.isOpen).thenReturn(true)
+
+ val proxy = YdbEntityManagerProxy.create(em)
+
+ assertTrue(proxy.isOpen)
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt
new file mode 100644
index 00000000..35b276f5
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/connection/YdbJpaKeycloakTransactionTest.kt
@@ -0,0 +1,87 @@
+package tech.ydb.keycloak.connection
+
+import jakarta.persistence.EntityManager
+import jakarta.persistence.EntityTransaction
+import jakarta.persistence.PersistenceException
+import jakarta.ws.rs.WebApplicationException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.keycloak.models.ModelException
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.mockito.kotlin.doReturn
+import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil
+
+class YdbJpaKeycloakTransactionTest {
+
+ @Test
+ fun retryableCommitErrorIsMappedTo503() {
+ val retryable = YdbRetryableExceptionUtil.ydbRetryableException("commit conflict")
+ val tx = mock()
+ doAnswer { throw retryable }.whenever(tx).commit()
+ val em = mock {
+ on { transaction } doReturn tx
+ }
+ val transaction = YdbJpaKeycloakTransaction(em)
+
+ val ex = assertThrows(WebApplicationException::class.java) { transaction.commit() }
+
+ assertEquals(503, ex.response.status)
+ assertEquals("application", ex.response.mediaType?.type)
+ assertEquals("json", ex.response.mediaType?.subtype)
+ assertSame(retryable, ex.cause)
+ val body = ex.response.entity as String
+ assertTrue(body.contains("\"error\":\"ydb_retryable\""))
+ assertTrue(body.contains("Transaction aborted, please retry"))
+ }
+
+ @Test
+ fun retryableErrorWrappedInPersistenceExceptionIsMappedTo503() {
+ val retryable = YdbRetryableExceptionUtil.ydbRetryableException()
+ val tx = mock {
+ on { commit() } doThrow PersistenceException(retryable)
+ }
+ val em = mock {
+ on { transaction } doReturn tx
+ }
+ val transaction = YdbJpaKeycloakTransaction(em)
+
+ val ex = assertThrows(WebApplicationException::class.java) { transaction.commit() }
+
+ assertEquals(503, ex.response.status)
+ assertTrue(ex.cause is ModelException)
+ assertSame(retryable, ex.cause?.cause)
+ }
+
+ @Test
+ fun nonRetryableCommitErrorIsRethrown() {
+ val failure = IllegalStateException("commit failed")
+ val tx = mock {
+ on { commit() } doThrow failure
+ }
+ val em = mock {
+ on { transaction } doReturn tx
+ }
+ val transaction = YdbJpaKeycloakTransaction(em)
+
+ val ex = assertThrows(IllegalStateException::class.java) { transaction.commit() }
+
+ assertSame(failure, ex)
+ }
+
+ @Test
+ fun successfulCommitDelegatesToEntityManager() {
+ val tx = mock()
+ val em = mock {
+ on { transaction } doReturn tx
+ }
+ val transaction = YdbJpaKeycloakTransaction(em)
+
+ transaction.commit()
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt
new file mode 100644
index 00000000..7d4ccb4a
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/error/YdbRetryableExceptionMappersTest.kt
@@ -0,0 +1,51 @@
+package tech.ydb.keycloak.error
+
+import jakarta.persistence.PersistenceException
+import jakarta.ws.rs.core.Response
+import org.hibernate.exception.GenericJDBCException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.keycloak.models.KeycloakSession
+import org.mockito.kotlin.mock
+import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil
+import tech.ydb.keycloak.utils.YdbRetryableResponses
+
+class YdbRetryableExceptionMappersTest {
+
+ @Test
+ fun genericJdbcExceptionMapperReturns503ForYdbRetryableCause() {
+ val retryable = YdbRetryableExceptionUtil.ydbRetryableException()
+ val mapper = YdbRetryableGenericJdbcExceptionMapper()
+ injectSession(mapper, mock())
+
+ val response = mapper.toResponse(GenericJDBCException("jdbc error", retryable))
+
+ assertEquals(503, response.status)
+ assertRetryableBody(response)
+ }
+
+ @Test
+ fun persistenceExceptionMapperReturns503ForYdbRetryableCause() {
+ val retryable = YdbRetryableExceptionUtil.ydbRetryableException()
+ val mapper = YdbRetryablePersistenceExceptionMapper()
+ injectSession(mapper, mock())
+
+ val response = mapper.toResponse(PersistenceException(retryable))
+
+ assertEquals(503, response.status)
+ assertRetryableBody(response)
+ }
+
+ private fun assertRetryableBody(response: Response) {
+ val body = response.entity as String
+ assertTrue(body.contains(YdbRetryableResponses.ERROR_CODE))
+ assertTrue(body.contains(YdbRetryableResponses.CONTENTION_DESCRIPTION))
+ }
+
+ private fun injectSession(mapper: Any, session: KeycloakSession) {
+ val field = mapper.javaClass.getDeclaredField("session")
+ field.isAccessible = true
+ field.set(mapper, session)
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt
new file mode 100644
index 00000000..c919c0d5
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/testsupport/YdbRetryableExceptionUtil.kt
@@ -0,0 +1,14 @@
+package tech.ydb.keycloak.testsupport
+
+import tech.ydb.core.Status
+import tech.ydb.core.StatusCode
+import tech.ydb.core.UnexpectedResultException
+import tech.ydb.jdbc.exception.ExceptionFactory
+import tech.ydb.jdbc.exception.YdbRetryableException
+
+object YdbRetryableExceptionUtil {
+ fun ydbRetryableException(message: String = "contention"): YdbRetryableException {
+ val unexpected = UnexpectedResultException(message, Status.of(StatusCode.ABORTED))
+ return ExceptionFactory.createException(message, unexpected) as YdbRetryableException
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt
new file mode 100644
index 00000000..e118f337
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbErrorUtilsTest.kt
@@ -0,0 +1,38 @@
+package tech.ydb.keycloak.utils
+
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil
+
+class YdbErrorUtilsTest {
+
+ @Test
+ fun returnsTrueForYdbRetryableException() {
+ assertTrue(isYdbRetryable(YdbRetryableExceptionUtil.ydbRetryableException()))
+ }
+
+ @Test
+ fun returnsTrueWhenYdbRetryableExceptionIsCause() {
+ val retryable = YdbRetryableExceptionUtil.ydbRetryableException()
+ val wrapped = RuntimeException("wrapper", retryable)
+ assertTrue(isYdbRetryable(wrapped))
+ }
+
+ @Test
+ fun returnsTrueForDeepCauseChain() {
+ val retryable = YdbRetryableExceptionUtil.ydbRetryableException()
+ val wrapped = IllegalStateException("outer", RuntimeException("middle", retryable))
+ assertTrue(isYdbRetryable(wrapped))
+ }
+
+ @Test
+ fun returnsFalseForUnrelatedException() {
+ assertFalse(isYdbRetryable(RuntimeException("fail")))
+ }
+
+ @Test
+ fun returnsFalseForExceptionWithoutRetryableCause() {
+ assertFalse(isYdbRetryable(RuntimeException("fail", IllegalStateException("inner"))))
+ }
+}
diff --git a/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt
new file mode 100644
index 00000000..b53c73c7
--- /dev/null
+++ b/keycloak-ydb-extension/core/src/test/kotlin/tech/ydb/keycloak/utils/YdbRetryableResponsesTest.kt
@@ -0,0 +1,23 @@
+package tech.ydb.keycloak.utils
+
+import jakarta.ws.rs.core.Response
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import tech.ydb.keycloak.testsupport.YdbRetryableExceptionUtil
+
+class YdbRetryableResponsesTest {
+
+ @Test
+ fun build503SetsExpectedHeadersAndBody() {
+ val cause = YdbRetryableExceptionUtil.ydbRetryableException()
+ val response = YdbRetryableResponses.build503(cause, YdbRetryableResponses.CONTENTION_DESCRIPTION)
+
+ assertEquals(Response.Status.SERVICE_UNAVAILABLE.statusCode, response.status)
+ assertEquals("application", response.mediaType?.type)
+ assertEquals("json", response.mediaType?.subtype)
+ val body = response.entity as String
+ assertTrue(body.contains("\"error\":\"${YdbRetryableResponses.ERROR_CODE}\""))
+ assertTrue(body.contains(YdbRetryableResponses.CONTENTION_DESCRIPTION))
+ }
+}
diff --git a/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf
new file mode 100644
index 00000000..13c5fa3b
--- /dev/null
+++ b/keycloak-ydb-extension/docker/conf/keycloak-remote-ydb.conf
@@ -0,0 +1,28 @@
+# Keycloak configuration for remote YDB (not in Docker)
+# Mount this as /opt/keycloak/conf/keycloak.conf
+
+# --- YDB / JPA (this extension) ---
+# REQUIRED: set YDB_JDBC_URL when starting the container (see docker-compose-remote-ydb.yml).
+# Example: jdbc:ydb:grpcs://.../path
+spi-connections-jpa-ydb-jdbc-url=${YDB_JDBC_URL}
+spi-connections-jpa-ydb-show-sql=false
+
+# --- HikariCP connection pool ---
+# spi-connections-jpa-ydb-pool-size=50
+# spi-connections-jpa-ydb-min-idle=10
+# spi-connections-jpa-ydb-connection-timeout=30000
+# spi-connections-jpa-ydb-idle-timeout=600000
+# spi-connections-jpa-ydb-max-lifetime=1800000
+
+# Disable default Quarkus JPA and Liquibase so only this extension is used
+spi-connections-jpa-quarkus-enabled=false
+spi-connections-liquibase-quarkus-enabled=false
+
+# --- Bootstrap admin (Keycloak 26.x) ---
+bootstrap-admin-username=admin
+bootstrap-admin-password=admin
+
+# --- Proxy settings (retry-proxy in front) ---
+proxy-headers=forwarded
+http-enabled=true
+hostname=http://localhost:9090
diff --git a/keycloak-ydb-extension/docker/conf/keycloak.conf b/keycloak-ydb-extension/docker/conf/keycloak.conf
new file mode 100644
index 00000000..c841b2f9
--- /dev/null
+++ b/keycloak-ydb-extension/docker/conf/keycloak.conf
@@ -0,0 +1,31 @@
+# Keycloak configuration for YDB extension (Docker Compose)
+# Mount this as /opt/keycloak/conf/keycloak.conf
+
+# --- YDB / JPA (this extension) ---
+# JDBC URL for YDB (required). Override with env for different hosts, e.g.:
+spi-connections-jpa-ydb-jdbc-url=jdbc:ydb:grpc://ydb:2136/local
+spi-connections-jpa-ydb-show-sql=false
+# spi-connections-jpa-ydb-format-sql=true
+
+# --- HikariCP connection pool ---
+# spi-connections-jpa-ydb-pool-size=50
+# spi-connections-jpa-ydb-min-idle=10
+# spi-connections-jpa-ydb-connection-timeout=30000
+# spi-connections-jpa-ydb-idle-timeout=600000
+# spi-connections-jpa-ydb-max-lifetime=1800000
+
+# --- YDB Liquibase provider ---
+# spi-connections-liquibase-ydb-liquibase-index-creation-threshold=300000
+
+# Disable default Quarkus JPA and Liquibase so only this extension is used
+spi-connections-jpa-quarkus-enabled=false
+spi-connections-liquibase-quarkus-enabled=false
+
+# --- Bootstrap admin (Keycloak 26.x) ---
+bootstrap-admin-username=admin
+bootstrap-admin-password=admin
+
+# --- Proxy settings (retry-proxy in front) ---
+proxy-headers=forwarded
+http-enabled=true
+hostname=http://0.0.0.0:9090
diff --git a/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml
new file mode 100644
index 00000000..944cfed3
--- /dev/null
+++ b/keycloak-ydb-extension/docker/docker-compose-remote-ydb.yml
@@ -0,0 +1,40 @@
+version: '3.4'
+
+networks:
+ keycloak:
+ driver: bridge
+
+services:
+ remote-ydb-keycloak:
+ image: quay.io/keycloak/keycloak:26.4.7
+ container_name: remote-ydb-keycloak
+ expose:
+ - '8080'
+ volumes:
+ - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar
+ - ./conf/keycloak-remote-ydb.conf:/opt/keycloak/conf/keycloak.conf
+ entrypoint: /opt/keycloak/bin/kc.sh
+ command: >
+ -v start-dev
+ --cache=local
+ environment:
+ - YDB_JDBC_URL=${YDB_JDBC_URL:?Set YDB_JDBC_URL, e.g. jdbc:ydb:grpc://host.docker.internal:2136/local}
+ networks:
+ - keycloak
+
+ remote-ydb-retry-proxy:
+ build:
+ context: ../retry-proxy
+ container_name: remote-ydb-retry-proxy
+ environment:
+ - TARGET_URL=http://remote-ydb-keycloak:8080
+ - MAX_RETRIES=10
+ - BASE_DELAY_MS=50
+ - MAX_DELAY_MS=2000
+ - LISTEN_PORT=8080
+ ports:
+ - '9090:8080'
+ networks:
+ - keycloak
+ depends_on:
+ - remote-ydb-keycloak
diff --git a/keycloak-ydb-extension/docker/docker-compose.yml b/keycloak-ydb-extension/docker/docker-compose.yml
new file mode 100644
index 00000000..90ff5f6f
--- /dev/null
+++ b/keycloak-ydb-extension/docker/docker-compose.yml
@@ -0,0 +1,72 @@
+version: '3.4'
+
+volumes:
+ ydb_data:
+ driver: local
+ ydb_certs:
+ driver: local
+
+networks:
+ keycloak:
+ driver: bridge
+
+services:
+ ydb:
+ image: ydbplatform/local-ydb:latest
+ platform: linux/amd64
+ ports:
+ - '2135:2135' # GRPC TLS
+ - '2136:2136' # GRPC
+ - '8765:8765' # Monitoring UI
+ - '9092:9092' # Kafka Proxy
+ volumes:
+ - 'ydb_certs:/ydb_certs'
+ - 'ydb_data:/ydb_data'
+ networks:
+ - keycloak
+ environment:
+ - GRPC_TLS_PORT=2135
+ - GRPC_PORT=2136
+ - MON_PORT=8765
+ - YDB_KAFKA_PROXY_PORT=9092
+ healthcheck:
+ test: [ "CMD-SHELL", "/bin/sh /health_check" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ ydb-keycloak:
+ image: quay.io/keycloak/keycloak:26.4.7
+ container_name: ydb-keycloak
+ expose:
+ - '8080'
+ volumes:
+ - ./providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar
+ - ./conf/keycloak.conf:/opt/keycloak/conf/keycloak.conf
+ entrypoint: /opt/keycloak/bin/kc.sh
+ command: >
+ -v start-dev
+ --cache=local
+ networks:
+ - keycloak
+ depends_on:
+ ydb:
+ condition: service_healthy
+
+ retry-proxy:
+ build:
+ context: ../retry-proxy
+ environment:
+ - TARGET_URL=http://ydb-keycloak:8080
+ - MAX_RETRIES=10
+ - BASE_DELAY_MS=50
+ - MAX_DELAY_MS=2000
+ - LISTEN_PORT=8080
+ ports:
+ - 9090:8080
+ networks:
+ - keycloak
+ depends_on:
+ ydb-keycloak:
+ condition: service_started
+ restart: on-failure
diff --git a/keycloak-ydb-extension/load-test/.gitignore b/keycloak-ydb-extension/load-test/.gitignore
new file mode 100644
index 00000000..4ca468ac
--- /dev/null
+++ b/keycloak-ydb-extension/load-test/.gitignore
@@ -0,0 +1,2 @@
+lib/
+results/
diff --git a/keycloak-ydb-extension/load-test/README.md b/keycloak-ydb-extension/load-test/README.md
new file mode 100644
index 00000000..bf66e636
--- /dev/null
+++ b/keycloak-ydb-extension/load-test/README.md
@@ -0,0 +1,249 @@
+# Load Testing
+
+Load tests for Keycloak using [keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark) (Gatling).
+
+Supports two infrastructure configurations:
+
+- **Keycloak + Local YDB** — YDB in Docker, retry-proxy in front of Keycloak
+- **Keycloak + Remote YDB** — external YDB instance, retry-proxy in front of Keycloak
+
+## Prerequisites
+
+- Java 21+
+- Python 3
+- Docker + Docker Compose
+
+## Quick Start
+
+## 1. Download keycloak-benchmark
+
+```bash
+./prepare.sh
+```
+
+Downloads the Gatling benchmark JARs from GitHub releases into `lib/`.
+To use a specific version:
+
+```bash
+./prepare.sh 26.4.0-SNAPSHOT
+```
+
+## 2. Start infrastructure
+
+All commands below are run from the `keycloak-ydb-extension/` root.
+
+### Option A: Keycloak + Local YDB
+
+```bash
+../run-keycloak-with-ydb.sh
+```
+
+This builds core + retry-proxy, copies the JAR, and starts Docker Compose (YDB + Keycloak + retry-proxy).
+
+Wait for Keycloak to start (~30-60s). Check logs:
+
+```bash
+docker compose -f docker/docker-compose.yml logs -f ydb-keycloak
+```
+
+| Service | URL |
+|----------------------------|-----------------------|
+| Keycloak (via retry-proxy) | http://localhost:9090 |
+| YDB Monitoring | http://localhost:8765 |
+
+### Option B: Keycloak + Remote YDB
+
+Start YDB separately, e.g.:
+
+```bash
+docker run -d --rm --name ydb-local -h localhost \
+ --platform linux/amd64 \
+ -p 2135:2135 -p 2136:2136 -p 8765:8765 \
+ -v $(pwd)/ydb_certs:/ydb_certs -v $(pwd)/ydb_data:/ydb_data \
+ -e GRPC_TLS_PORT=2135 -e GRPC_PORT=2136 -e MON_PORT=8765 \
+ ydbplatform/local-ydb:latest
+```
+
+Then start Keycloak + retry-proxy:
+
+```bash
+YDB_JDBC_URL="jdbc:ydb:grpc://host.docker.internal:2136/local" \
+ docker compose -f docker/docker-compose-remote-ydb.yml up -d --build
+```
+
+For a cloud YDB instance:
+
+```bash
+YDB_JDBC_URL="jdbc:ydb:grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/..." \
+ docker compose -f docker/docker-compose-remote-ydb.yml up -d --build
+```
+
+| Service | URL |
+|----------------------------|-----------------------|
+| Keycloak (via retry-proxy) | http://localhost:9090 |
+
+Admin credentials for all options: `admin` / `admin`
+
+### Comparison with other databases
+
+For benchmarking Keycloak with other databases (PostgreSQL, MySQL, etc.), use the setups from the keycloak-benchmark repository:
+[keycloak-benchmark](https://github.com/keycloak/keycloak-benchmark/tree/main/provision)
+
+## 3. Setup test realm
+
+```bash
+python3 setup-test-realm.py
+```
+
+Creates `test-realm` with clients (`gatling`, `client-0`, `test-client`), roles, groups, and test users.
+
+## 4. Run load test
+
+All commands are run from the `load-test/` directory.
+
+First, build the classpath:
+
+```bash
+CLASSPATH=$(find lib -name '*.jar' | tr '\n' ':')
+```
+
+---
+
+### Admin scenarios using service account (CreateUsers, CreateDeleteUsers, CreateClients, CreateDeleteClients)
+
+These scenarios authenticate via the `gatling` service account created by `setup-test-realm.py`.
+
+```bash
+java -server -Xmx1G \
+ -Dserver-url=http://localhost:9090 \
+ -Drealm-name=test-realm \
+ -Dclient-id=gatling \
+ -Dclient-secret=setup-for-benchmark \
+ -Dusers-per-sec=10 \
+ -Dmeasurement=60 \
+ -cp "$CLASSPATH" \
+ io.gatling.app.Gatling \
+ -rf results \
+ -s keycloak.scenario.admin.CreateUsers
+```
+
+Swap `-s` for any of:
+- `keycloak.scenario.admin.CreateUsers`
+- `keycloak.scenario.admin.CreateDeleteUsers`
+- `keycloak.scenario.admin.CreateClients`
+- `keycloak.scenario.admin.CreateDeleteClients`
+
+---
+
+### Admin scenarios using admin account (CreateRealms, CreateDeleteRealms, ListSessions)
+
+These scenarios authenticate as the Keycloak admin user.
+
+```bash
+java -server -Xmx1G \
+ -Dserver-url=http://localhost:9090 \
+ -Drealm-name=test-realm \
+ -Dclient-id=gatling \
+ -Dclient-secret=setup-for-benchmark \
+ -Dadmin-username=admin \
+ -Dadmin-password=admin \
+ -Dusers-per-sec=10 \
+ -Dmeasurement=60 \
+ -cp "$CLASSPATH" \
+ io.gatling.app.Gatling \
+ -rf results \
+ -s keycloak.scenario.admin.CreateRealms
+```
+
+Swap `-s` for any of:
+- `keycloak.scenario.admin.CreateRealms`
+- `keycloak.scenario.admin.CreateDeleteRealms`
+- `keycloak.scenario.admin.ListSessions`
+
+---
+
+### Authentication scenario: Client Credentials (ClientSecret)
+
+Authenticates via `client_credentials` grant — no user login required.
+
+```bash
+java -server -Xmx1G \
+ -Dserver-url=http://localhost:9090 \
+ -Drealm-name=test-realm \
+ -Dclient-id=gatling \
+ -Dclient-secret=setup-for-benchmark \
+ -Dusers-per-sec=10 \
+ -Dmeasurement=60 \
+ -cp "$CLASSPATH" \
+ io.gatling.app.Gatling \
+ -rf results \
+ -s keycloak.scenario.authentication.ClientSecret
+```
+
+---
+
+### Authentication scenarios: User Login (AuthorizationCode, LoginUserPassword)
+
+These scenarios simulate real user logins. They require `http://0.0.0.0:9090` instead of `localhost` —
+Gatling refuses to send secure cookies to localhost with Keycloak 26
+(see [keycloak-benchmark#945](https://github.com/keycloak/keycloak-benchmark/issues/945)).
+Also make sure `hostname` in `docker/conf/keycloak.conf` matches this address (see retry-proxy README).
+
+By default, users `user-0`, `user-1`, ... with passwords `user-0-password`, `user-1-password`, ... are used (created by `setup-test-realm.py`).
+
+```bash
+java -server -Xmx1G \
+ -Dserver-url=http://0.0.0.0:9090 \
+ -Drealm-name=test-realm \
+ -Dclient-id=gatling \
+ -Dclient-secret=setup-for-benchmark \
+ -Dusers-per-sec=10 \
+ -Dmeasurement=60 \
+ -cp "$CLASSPATH" \
+ io.gatling.app.Gatling \
+ -rf results \
+ -s keycloak.scenario.authentication.AuthorizationCode
+```
+
+Swap `-s` for any of:
+- `keycloak.scenario.authentication.AuthorizationCode`
+- `keycloak.scenario.authentication.LoginUserPassword`
+
+Results are saved to `results/` with Gatling HTML reports.
+
+## 5. Cleanup between runs
+
+Delete all users from test-realm:
+
+```bash
+python3 delete-all-users.py
+```
+
+## Available Scenarios
+
+| Scenario | Auth method | localhost ok? |
+|---|---|:---:|
+| `keycloak.scenario.admin.CreateUsers` | service account | yes |
+| `keycloak.scenario.admin.CreateDeleteUsers` | service account | yes |
+| `keycloak.scenario.admin.CreateClients` | service account | yes |
+| `keycloak.scenario.admin.CreateDeleteClients` | service account | yes |
+| `keycloak.scenario.admin.CreateRealms` | admin account | yes |
+| `keycloak.scenario.admin.CreateDeleteRealms` | admin account | yes |
+| `keycloak.scenario.admin.ListSessions` | admin account | yes |
+| `keycloak.scenario.authentication.ClientSecret` | client credentials | yes |
+| `keycloak.scenario.authentication.AuthorizationCode` | user login | **no** — use `0.0.0.0` |
+| `keycloak.scenario.authentication.LoginUserPassword` | user login | **no** — use `0.0.0.0` |
+
+Full list of scenarios:
+[keycloak-benchmark/scenario](https://github.com/keycloak/keycloak-benchmark/tree/main/benchmark/src/main/scala/keycloak/scenario)
+
+## Directory Structure
+
+```
+load-test/
+ prepare.sh # Downloads keycloak-benchmark from GitHub
+ setup-test-realm.py # Creates test realm, clients, users
+ delete-all-users.py # Deletes all users from realm
+ lib/ # Benchmark JARs (gitignored)
+ results/ # Gatling reports (gitignored)
+```
diff --git a/keycloak-ydb-extension/load-test/delete-all-users.py b/keycloak-ydb-extension/load-test/delete-all-users.py
new file mode 100644
index 00000000..149af7e5
--- /dev/null
+++ b/keycloak-ydb-extension/load-test/delete-all-users.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""
+Deletes all users from test-realm in Keycloak (batched).
+
+Usage:
+ python3 delete-all-users.py [KC_URL] [REALM]
+ KC_URL defaults to http://localhost:9090
+ REALM defaults to test-realm
+"""
+
+import sys
+import urllib.request
+import urllib.parse
+import urllib.error
+import json
+
+KC_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:9090"
+REALM = sys.argv[2] if len(sys.argv) > 2 else "test-realm"
+ADMIN_USER = "admin"
+ADMIN_PASS = "admin"
+BATCH_SIZE = 100
+
+
+def api(method, path, data=None, token=None):
+ url = KC_URL + path
+ headers = {}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ if data is not None and method != "GET":
+ headers["Content-Type"] = "application/json"
+ body = json.dumps(data).encode()
+ else:
+ body = None
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
+ try:
+ resp = urllib.request.urlopen(req)
+ content = resp.read().decode()
+ return resp.status, json.loads(content) if content else None
+ except urllib.error.HTTPError as e:
+ content = e.read().decode()
+ try:
+ return e.code, json.loads(content)
+ except Exception:
+ return e.code, content
+
+
+def get_token():
+ data = urllib.parse.urlencode({
+ "username": ADMIN_USER, "password": ADMIN_PASS,
+ "grant_type": "password", "client_id": "admin-cli"
+ }).encode()
+ req = urllib.request.Request(
+ KC_URL + f"/realms/master/protocol/openid-connect/token",
+ data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req)
+ return json.loads(resp.read().decode())["access_token"]
+
+
+def main():
+ print(f"Keycloak: {KC_URL}")
+ print(f"Realm: {REALM}")
+ print()
+
+ token = get_token()
+ total_deleted = 0
+
+ while True:
+ code, users = api("GET", f"/admin/realms/{REALM}/users?max={BATCH_SIZE}", token=token)
+ if code != 200 or not users:
+ break
+
+ print(f"Fetched {len(users)} users...")
+ for u in users:
+ username = u["username"]
+ uid = u["id"]
+ code, _ = api("DELETE", f"/admin/realms/{REALM}/users/{uid}", token=token)
+ if code == 204:
+ total_deleted += 1
+ else:
+ print(f" Failed to delete {username}: {code}")
+
+ print(f" Deleted batch. Total so far: {total_deleted}")
+
+ # Refresh token periodically
+ if total_deleted % 500 == 0:
+ token = get_token()
+
+ print(f"\nDone. Deleted {total_deleted} users from {REALM}.")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/keycloak-ydb-extension/load-test/prepare.sh b/keycloak-ydb-extension/load-test/prepare.sh
new file mode 100755
index 00000000..9df70063
--- /dev/null
+++ b/keycloak-ydb-extension/load-test/prepare.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+#
+# Downloads keycloak-benchmark from GitHub releases and extracts JARs into lib/.
+#
+# Usage: ./prepare.sh [version]
+# version defaults to 999.0.0-SNAPSHOT
+#
+# Examples:
+# ./prepare.sh
+# ./prepare.sh 26.4.0-SNAPSHOT
+#
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+LIB_DIR="$SCRIPT_DIR/lib"
+VERSION="${1:-999.0.0-SNAPSHOT}"
+ARCHIVE="keycloak-benchmark-${VERSION}.tar.gz"
+URL="https://github.com/keycloak/keycloak-benchmark/releases/download/${VERSION}/${ARCHIVE}"
+
+mkdir -p "$LIB_DIR"
+
+echo "Downloading keycloak-benchmark ${VERSION} ..."
+echo " URL: $URL"
+
+TMP_DIR=$(mktemp -d)
+trap "rm -rf $TMP_DIR" EXIT
+
+curl -fSL --progress-bar -o "$TMP_DIR/$ARCHIVE" "$URL"
+
+echo "Extracting ..."
+tar -xzf "$TMP_DIR/$ARCHIVE" -C "$TMP_DIR"
+
+DIST_DIR="$TMP_DIR/keycloak-benchmark-${VERSION}"
+if [ ! -d "$DIST_DIR/lib" ]; then
+ echo "ERROR: Unexpected archive structure, lib/ not found in $DIST_DIR"
+ ls "$TMP_DIR"
+ exit 1
+fi
+
+rm -f "$LIB_DIR"/*.jar
+cp "$DIST_DIR/lib/"*.jar "$LIB_DIR/"
+
+echo
+echo "Done. JARs in $LIB_DIR/:"
+ls -lh "$LIB_DIR/"
diff --git a/keycloak-ydb-extension/load-test/setup-test-realm.py b/keycloak-ydb-extension/load-test/setup-test-realm.py
new file mode 100644
index 00000000..6b4dbd9a
--- /dev/null
+++ b/keycloak-ydb-extension/load-test/setup-test-realm.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+"""
+Sets up a test realm in Keycloak for load testing.
+
+Creates:
+ - test-realm with registration enabled
+ - Clients: gatling (service account), client-0 (OIDC), test-client (public)
+ - Roles: user, admin, manager
+ - Groups: developers, testers, devops
+ - 10 test users (testuser1..10) with passwords, roles, and groups
+ - 1 benchmark user (user-0) for keycloak-benchmark scenarios
+
+Usage:
+ python3 setup-test-realm.py [KC_URL]
+ KC_URL defaults to http://localhost:9090
+"""
+
+import sys
+import urllib.request
+import urllib.parse
+import urllib.error
+import json
+
+KC_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:9090"
+ADMIN_USER = "admin"
+ADMIN_PASS = "admin"
+
+
+def api(method, path, data=None, token=None):
+ url = KC_URL + path
+ headers = {}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ if data is not None and method != "GET":
+ headers["Content-Type"] = "application/json"
+ body = json.dumps(data).encode()
+ else:
+ body = None
+
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
+ try:
+ resp = urllib.request.urlopen(req)
+ content = resp.read().decode()
+ return resp.status, json.loads(content) if content else None
+ except urllib.error.HTTPError as e:
+ content = e.read().decode()
+ try:
+ return e.code, json.loads(content)
+ except Exception:
+ return e.code, content
+
+
+def get_token():
+ data = urllib.parse.urlencode({
+ "username": ADMIN_USER, "password": ADMIN_PASS,
+ "grant_type": "password", "client_id": "admin-cli"
+ }).encode()
+ req = urllib.request.Request(
+ KC_URL + "/realms/master/protocol/openid-connect/token",
+ data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req)
+ return json.loads(resp.read().decode())["access_token"]
+
+
+def ok(status):
+ return 200 <= status < 300
+
+
+def main():
+ print(f"Keycloak URL: {KC_URL}")
+ print(f"Admin credentials: {ADMIN_USER}/{ADMIN_PASS}")
+ print()
+
+ # 1. Token
+ print("=== Getting admin token ===")
+ token = get_token()
+ print(f" Token: {token[:30]}...")
+ print()
+
+ # 2. Realm
+ print("=== Creating realm: test-realm ===")
+ code, resp = api("POST", "/admin/realms", data={
+ "realm": "test-realm",
+ "enabled": True,
+ "displayName": "Test Realm for Load Testing",
+ "registrationAllowed": True,
+ "loginWithEmailAllowed": True
+ }, token=token)
+ if code == 201:
+ print(" CREATED (201)")
+ elif code == 409:
+ print(" Already exists (409), skipping")
+ else:
+ print(f" FAILED ({code}): {resp}")
+ sys.exit(1)
+ print()
+
+ # 3. Clients
+ print("=== Creating clients ===")
+ clients_spec = [
+ {
+ "clientId": "gatling", "enabled": True,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "setup-for-benchmark",
+ "redirectUris": ["*"], "serviceAccountsEnabled": True,
+ "publicClient": False, "protocol": "openid-connect",
+ "attributes": {"post.logout.redirect.uris": "+"}
+ },
+ {
+ "clientId": "client-0", "enabled": True,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "client-0-secret",
+ "redirectUris": ["*"], "serviceAccountsEnabled": True,
+ "publicClient": False, "protocol": "openid-connect",
+ "attributes": {"post.logout.redirect.uris": "+"}
+ },
+ {
+ "clientId": "test-client", "enabled": True,
+ "directAccessGrantsEnabled": True,
+ "publicClient": True,
+ "redirectUris": ["*"], "webOrigins": ["*"]
+ },
+ ]
+ for c in clients_spec:
+ code, _ = api("POST", "/admin/realms/test-realm/clients", data=c, token=token)
+ if code == 409:
+ # Client exists — update its secret and settings
+ _, existing = api("GET", f"/admin/realms/test-realm/clients?clientId={c['clientId']}", token=token)
+ if existing:
+ client_uuid = existing[0]["id"]
+ update_code, _ = api("PUT", f"/admin/realms/test-realm/clients/{client_uuid}", data={**existing[0], **c}, token=token)
+ status_msg = "UPDATED" if update_code == 204 else f"update FAILED ({update_code})"
+ else:
+ status_msg = "already exists (could not update)"
+ elif code == 201:
+ status_msg = "CREATED"
+ else:
+ status_msg = f"FAILED ({code})"
+ print(f" {c['clientId']}: {status_msg}")
+
+ # Assign realm-management roles to gatling service account
+ print(" Assigning realm-management roles to gatling service account...")
+ code, clients = api("GET", "/admin/realms/test-realm/clients?clientId=gatling", token=token)
+ gatling_id = clients[0]["id"]
+ code, sa_user = api("GET", f"/admin/realms/test-realm/clients/{gatling_id}/service-account-user", token=token)
+ sa_user_id = sa_user["id"]
+ code, rm_clients = api("GET", "/admin/realms/test-realm/clients?clientId=realm-management", token=token)
+ rm_id = rm_clients[0]["id"]
+
+ roles_to_assign = []
+ for role_name in ["manage-clients", "view-users", "manage-realm", "manage-users", "query-users", "query-groups"]:
+ code, role = api("GET", f"/admin/realms/test-realm/clients/{rm_id}/roles/{role_name}", token=token)
+ roles_to_assign.append(role)
+
+ code, _ = api("POST",
+ f"/admin/realms/test-realm/users/{sa_user_id}/role-mappings/clients/{rm_id}",
+ data=roles_to_assign, token=token)
+ print(f" Roles assigned: {code}")
+ print()
+
+ # 4. Realm roles
+ print("=== Creating realm roles ===")
+ for role_name in ["user", "admin", "manager"]:
+ code, _ = api("POST", "/admin/realms/test-realm/roles",
+ data={"name": role_name, "description": f"{role_name} role"}, token=token)
+ status_msg = "CREATED" if code == 201 else "already exists" if code == 409 else f"FAILED ({code})"
+ print(f" {role_name}: {status_msg}")
+ print()
+
+ # 5. Groups
+ print("=== Creating groups ===")
+ for group_name in ["developers", "testers", "devops"]:
+ code, _ = api("POST", "/admin/realms/test-realm/groups",
+ data={"name": group_name}, token=token)
+ status_msg = "CREATED" if code == 201 else "already exists" if code == 409 else f"FAILED ({code})"
+ print(f" {group_name}: {status_msg}")
+ print()
+
+ # Fetch roles and groups for assignment
+ roles = {}
+ for rname in ["user", "admin", "manager"]:
+ code, data = api("GET", f"/admin/realms/test-realm/roles/{rname}", token=token)
+ roles[rname] = data
+
+ code, groups_data = api("GET", "/admin/realms/test-realm/groups", token=token)
+ groups = {g["name"]: g["id"] for g in groups_data}
+
+ role_list = ["user", "admin", "manager"]
+ group_list = ["developers", "testers", "devops"]
+
+ # 6. Test users
+ print("=== Creating test users ===")
+ for i in range(1, 11):
+ uname = f"testuser{i}"
+ role_name = role_list[(i - 1) % 3]
+ group_name = group_list[(i - 1) % 3]
+
+ code, _ = api("POST", "/admin/realms/test-realm/users", data={
+ "username": uname,
+ "email": f"{uname}@example.com",
+ "firstName": "Test",
+ "lastName": f"User{i}",
+ "enabled": True,
+ "credentials": [{"type": "password", "value": "password", "temporary": False}]
+ }, token=token)
+
+ if code == 201:
+ code2, users = api("GET",
+ f"/admin/realms/test-realm/users?username={uname}&exact=true", token=token)
+ user_id = users[0]["id"]
+
+ api("POST", f"/admin/realms/test-realm/users/{user_id}/role-mappings/realm",
+ data=[roles[role_name]], token=token)
+ api("PUT", f"/admin/realms/test-realm/users/{user_id}/groups/{groups[group_name]}",
+ data={}, token=token)
+
+ print(f" {uname}: CREATED | role={role_name} | group={group_name}")
+ elif code == 409:
+ print(f" {uname}: already exists, skipping")
+ else:
+ print(f" {uname}: FAILED ({code})")
+
+ # 7. Benchmark user (user-0)
+ print()
+ print("=== Creating benchmark user (user-0) ===")
+ code, _ = api("POST", "/admin/realms/test-realm/users", data={
+ "username": "user-0",
+ "enabled": True,
+ "firstName": "Firstname",
+ "lastName": "Lastname",
+ "email": "user-0@keycloak.org",
+ "credentials": [{"type": "password", "value": "user-0-password", "temporary": False}]
+ }, token=token)
+ status_msg = "CREATED" if code == 201 else "already exists" if code == 409 else f"FAILED ({code})"
+ print(f" user-0: {status_msg}")
+ print()
+
+ # 8. Verification
+ print("=== Verification ===")
+
+ # Users
+ code, users = api("GET", "/admin/realms/test-realm/users?max=50", token=token)
+ print(f" Total users in test-realm: {len(users)}")
+ for u in users:
+ code, rm = api("GET",
+ f"/admin/realms/test-realm/users/{u['id']}/role-mappings/realm", token=token)
+ user_roles = [r["name"] for r in (rm or []) if not r["name"].startswith("default-roles")]
+ code, ug = api("GET",
+ f"/admin/realms/test-realm/users/{u['id']}/groups", token=token)
+ user_groups = [g["name"] for g in (ug or [])]
+ print(f" {u['username']:15} | {u.get('email',''):25} | roles={user_roles} | groups={user_groups}")
+
+ # Clients
+ code, all_clients = api("GET", "/admin/realms/test-realm/clients", token=token)
+ custom_clients = [c for c in all_clients if c["clientId"] in ("gatling", "client-0", "test-client")]
+ print(f"\n Custom clients: {[c['clientId'] for c in custom_clients]}")
+
+ # Test login
+ print("\n=== Testing login ===")
+ try:
+ login_data = urllib.parse.urlencode({
+ "username": "testuser1", "password": "password",
+ "grant_type": "password", "client_id": "test-client"
+ }).encode()
+ req = urllib.request.Request(
+ KC_URL + "/realms/test-realm/protocol/openid-connect/token",
+ data=login_data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req)
+ data = json.loads(resp.read().decode())
+ print(f" testuser1 login: SUCCESS (token expires in {data['expires_in']}s)")
+ except urllib.error.HTTPError as e:
+ print(f" testuser1 login: FAILED ({e.code})")
+
+ print()
+ print("Setup complete!")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/keycloak-ydb-extension/pom.xml b/keycloak-ydb-extension/pom.xml
new file mode 100644
index 00000000..14a6409b
--- /dev/null
+++ b/keycloak-ydb-extension/pom.xml
@@ -0,0 +1,111 @@
+
+
+ 4.0.0
+
+ tech.ydb
+ keycloak-ydb-extension-parent
+ 1.0-SNAPSHOT
+ pom
+
+ keycloak-ydb-extension-parent
+ Keycloak extension storing data in YDB
+
+
+ core
+ test
+ retry-proxy
+
+
+
+ 21
+ ${java.version}
+ ${java.version}
+ ${java.version}
+
+ UTF-8
+
+ 2.2.21
+ official
+ 21
+
+ 26.4.7
+
+ 2.3.20
+ 1.1.1
+
+ 7.0.2
+ 0.9.2
+
+ 3.4.1
+ 1.5.16
+
+
+
+
+
+ org.keycloak
+ keycloak-parent
+ ${keycloak.version}
+ pom
+ import
+
+
+ org.junit
+ junit-bom
+ 5.14.3
+ pom
+ import
+
+
+ tech.ydb
+ keycloak-ydb-extension
+ ${project.version}
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+ ${kotlin.version}
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.2.1
+
+
+ attach-sources
+
+ jar-no-fork
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
+
+ maven-failsafe-plugin
+ 3.5.4
+
+
+
+
+
diff --git a/keycloak-ydb-extension/retry-proxy/Dockerfile b/keycloak-ydb-extension/retry-proxy/Dockerfile
new file mode 100644
index 00000000..2b38ec93
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/Dockerfile
@@ -0,0 +1,10 @@
+FROM eclipse-temurin:21-jre-alpine
+WORKDIR /app
+COPY target/keycloak-ydb-retry-proxy-1.0-SNAPSHOT.jar retry-proxy.jar
+ENV TARGET_URL=http://keycloak:8080
+ENV MAX_RETRIES=10
+ENV BASE_DELAY_MS=50
+ENV MAX_DELAY_MS=2000
+ENV LISTEN_PORT=8080
+EXPOSE 8080
+CMD ["java", "-jar", "retry-proxy.jar"]
diff --git a/keycloak-ydb-extension/retry-proxy/README.md b/keycloak-ydb-extension/retry-proxy/README.md
new file mode 100644
index 00000000..cfbf3757
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/README.md
@@ -0,0 +1,51 @@
+# YDB Retry Proxy
+
+HTTP reverse proxy for Keycloak that automatically retries requests on YDB-specific retryable errors.
+
+## Overview
+
+The proxy sits between the client and Keycloak, intercepting all HTTP traffic. When the backend returns a `503` response
+with a body containing `ydb_retryable`, the proxy transparently retries the request using exponential backoff with
+jitter.
+
+Key behaviors:
+
+- Retries only on `503` responses containing `ydb_retryable` in the body
+- Exponential backoff with jitter: `baseDelay * 2^attempt`, capped at `maxDelay`
+- Stops retrying if the client disconnects
+- Returns `502 Bad Gateway` on connection errors (timeout, DNS, IOException) without retrying
+- Filters hop-by-hop headers (RFC 2616 Section 13.5.1)
+- Rewrites `Location` headers from the internal target address to the proxy address
+
+## Configuration
+
+All parameters are set via environment variables.
+
+### Proxy
+
+| Variable | Default | Description |
+|-----------------|-------------------------|-----------------------------------------------|
+| `TARGET_URL` | `http://localhost:8080` | URL of the target server (Keycloak) |
+| `LISTEN_PORT` | `8080` | Port the proxy listens on |
+| `MAX_RETRIES` | `10` | Maximum number of retries on retryable errors |
+| `BASE_DELAY_MS` | `50` | Base delay before the first retry (ms) |
+| `MAX_DELAY_MS` | `2000` | Maximum delay between retries (ms) |
+
+### Keycloak configuration
+
+When Keycloak is deployed behind this proxy, its `hostname` setting must match the public address the proxy is exposed on — not `localhost`. Otherwise Keycloak generates login form URLs pointing to a different origin than the client connected to, which breaks the session cookie flow (`cookie_not_found`).
+
+Example (`keycloak.conf`):
+
+```properties
+proxy-headers=forwarded
+hostname=http://0.0.0.0:9090 # must match the address clients use to reach the proxy
+```
+
+### HTTP Client
+
+| Variable | Default | Description |
+|-----------------------------|---------|------------------------------------------|
+| `CLIENT_MAX_CONNECTIONS` | `1000` | Maximum number of concurrent connections |
+| `CLIENT_CONNECT_TIMEOUT_MS` | `10000` | Connection establishment timeout (ms) |
+| `CLIENT_REQUEST_TIMEOUT_MS` | `30000` | Response wait timeout (ms) |
diff --git a/keycloak-ydb-extension/retry-proxy/pom.xml b/keycloak-ydb-extension/retry-proxy/pom.xml
new file mode 100644
index 00000000..8b695e29
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/pom.xml
@@ -0,0 +1,175 @@
+
+
+ 4.0.0
+
+
+ tech.ydb
+ keycloak-ydb-extension-parent
+ 1.0-SNAPSHOT
+ ../pom.xml
+
+
+ jar
+ keycloak-ydb-extension (retry-proxy)
+ keycloak-ydb-retry-proxy
+ 1.0-SNAPSHOT
+
+
+ src/main/kotlin
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+
+
+
+ default-compile
+ none
+
+
+ default-testCompile
+ none
+
+
+ java-compile
+
+ compile
+
+ compile
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+ ${kotlin.version}
+
+
+ compile
+
+ compile
+
+
+
+ ${project.basedir}/src/main/kotlin
+
+
+
+
+ test-compile
+
+ test-compile
+
+
+
+ ${project.basedir}/src/test/kotlin
+
+
+
+
+
+
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+
+
+ tech.ydb.keycloak.proxy.RetryProxyKt
+
+
+
+
+
+
+
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+ ${kotlin.version}
+
+
+
+
+ io.ktor
+ ktor-server-core-jvm
+ ${ktor.version}
+
+
+ io.ktor
+ ktor-server-netty-jvm
+ ${ktor.version}
+
+
+ io.ktor
+ ktor-server-forwarded-header-jvm
+ ${ktor.version}
+
+
+
+
+ io.ktor
+ ktor-client-core-jvm
+ ${ktor.version}
+
+
+ io.ktor
+ ktor-client-cio-jvm
+ ${ktor.version}
+
+
+
+
+ ch.qos.logback
+ logback-classic
+ ${logback.version}
+
+
+
+
+ io.ktor
+ ktor-server-test-host-jvm
+ ${ktor.version}
+ test
+
+
+ io.ktor
+ ktor-client-mock-jvm
+ ${ktor.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.14.3
+ test
+
+
+ io.mockk
+ mockk-jvm
+ 1.13.16
+ test
+
+
+ org.jetbrains.kotlinx
+ kotlinx-coroutines-test
+ 1.10.2
+ test
+
+
+
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt
new file mode 100644
index 00000000..31a3ac8f
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/Dependencies.kt
@@ -0,0 +1,14 @@
+package tech.ydb.keycloak.proxy
+
+import io.ktor.client.*
+import tech.ydb.keycloak.proxy.client.createProxyClient
+import tech.ydb.keycloak.proxy.config.ProxyConfig
+import tech.ydb.keycloak.proxy.controller.ProxyController
+import tech.ydb.keycloak.proxy.service.ProxyService
+
+class Dependencies(
+ val config: ProxyConfig = ProxyConfig.fromEnv(),
+ val client: HttpClient = createProxyClient(config.client),
+ val proxyService: ProxyService = ProxyService(client, config),
+ val controller: ProxyController = ProxyController(proxyService, config),
+)
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt
new file mode 100644
index 00000000..9a3cb593
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/RetryProxy.kt
@@ -0,0 +1,19 @@
+package tech.ydb.keycloak.proxy
+
+import io.ktor.server.engine.*
+import io.ktor.server.netty.*
+import org.slf4j.LoggerFactory
+import tech.ydb.keycloak.proxy.plugins.configureRouting
+
+private val log = LoggerFactory.getLogger("RetryProxy")
+
+fun main() {
+ val deps = Dependencies()
+ val config = deps.config
+
+ log.info("Starting retry proxy: listen=:${config.listenPort} target=${config.targetUrl} maxRetries=${config.maxRetries} baseDelay=${config.baseDelayMs}ms maxDelay=${config.maxDelayMs}ms")
+
+ embeddedServer(Netty, port = config.listenPort) {
+ configureRouting(deps)
+ }.start(wait = true)
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt
new file mode 100644
index 00000000..a7b6ec57
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/client/ProxyClient.kt
@@ -0,0 +1,17 @@
+package tech.ydb.keycloak.proxy.client
+
+import io.ktor.client.*
+import io.ktor.client.engine.cio.*
+import tech.ydb.keycloak.proxy.config.ClientConfig
+
+fun createProxyClient(config: ClientConfig): HttpClient = HttpClient(CIO) {
+ engine {
+ maxConnectionsCount = config.maxConnectionsCount
+ endpoint {
+ connectTimeout = config.connectTimeoutMs
+ requestTimeout = config.requestTimeoutMs
+ }
+ }
+ expectSuccess = false
+ followRedirects = false
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt
new file mode 100644
index 00000000..e19fd3df
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/config/ProxyConfig.kt
@@ -0,0 +1,35 @@
+package tech.ydb.keycloak.proxy.config
+
+data class ClientConfig(
+ val maxConnectionsCount: Int,
+ val connectTimeoutMs: Long,
+ val requestTimeoutMs: Long,
+) {
+ companion object {
+ fun fromEnv(): ClientConfig = ClientConfig(
+ maxConnectionsCount = (System.getenv("CLIENT_MAX_CONNECTIONS") ?: "1000").toInt(),
+ connectTimeoutMs = (System.getenv("CLIENT_CONNECT_TIMEOUT_MS") ?: "10000").toLong(),
+ requestTimeoutMs = (System.getenv("CLIENT_REQUEST_TIMEOUT_MS") ?: "30000").toLong(),
+ )
+ }
+}
+
+data class ProxyConfig(
+ val targetUrl: String,
+ val maxRetries: Int,
+ val baseDelayMs: Long,
+ val maxDelayMs: Long,
+ val listenPort: Int,
+ val client: ClientConfig,
+) {
+ companion object {
+ fun fromEnv(): ProxyConfig = ProxyConfig(
+ targetUrl = System.getenv("TARGET_URL") ?: "http://localhost:8080",
+ maxRetries = (System.getenv("MAX_RETRIES") ?: "10").toInt(),
+ baseDelayMs = (System.getenv("BASE_DELAY_MS") ?: "50").toLong(),
+ maxDelayMs = (System.getenv("MAX_DELAY_MS") ?: "2000").toLong(),
+ listenPort = (System.getenv("LISTEN_PORT") ?: "8080").toInt(),
+ client = ClientConfig.fromEnv(),
+ )
+ }
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt
new file mode 100644
index 00000000..46e707a6
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/controller/ProxyController.kt
@@ -0,0 +1,73 @@
+package tech.ydb.keycloak.proxy.controller
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.plugins.origin
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import tech.ydb.keycloak.proxy.config.ProxyConfig
+import tech.ydb.keycloak.proxy.service.ProxyResult.*
+import tech.ydb.keycloak.proxy.service.ProxyService
+import tech.ydb.keycloak.proxy.utils.isHeader
+import tech.ydb.keycloak.proxy.utils.isHopByHop
+
+class ProxyController(
+ private val proxyService: ProxyService,
+ private val config: ProxyConfig,
+) {
+ suspend fun handle(call: ApplicationCall) {
+ val method = call.request.httpMethod
+ val path = call.request.uri
+ val body = call.receive()
+ val contentType = call.request.contentType()
+ val host = call.request.headers["Host"] ?: call.request.host()
+ val remoteHost = call.request.origin.remoteHost
+ val scheme = call.request.origin.scheme
+
+ val result = proxyService.proxyRequest(
+ method = method,
+ path = path,
+ body = body,
+ contentType = contentType,
+ headers = call.request.headers,
+ host = host,
+ remoteHost = remoteHost,
+ scheme = scheme
+ )
+
+ when (result) {
+ is Success -> call.handleSuccess(result)
+
+ is Error -> call.respondText(result.message, status = HttpStatusCode.BadGateway)
+
+ is ClientDisconnected -> {}
+ }
+ }
+
+ private suspend fun ApplicationCall.handleSuccess(result: Success) {
+ val originalHost = request.headers["Host"] ?: "localhost:${config.listenPort}"
+ val scheme = request.origin.scheme
+
+ result.headers.forEach { name, values ->
+ if (!isHopByHop(name) && !isHeader(name, HttpHeaders.ContentLength)) {
+ values.forEach { value ->
+ response.header(name, rewriteInternalUrl(name, value, originalHost, scheme))
+ }
+ }
+ }
+
+ respondBytes(result.body, result.contentType, result.status)
+ }
+
+ private fun rewriteInternalUrl(
+ name: String,
+ value: String,
+ originalHost: String,
+ scheme: String,
+ ): String {
+ if (isHeader(name, HttpHeaders.Location)) {
+ return value.replace(config.targetUrl, "$scheme://$originalHost")
+ }
+ return value
+ }
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt
new file mode 100644
index 00000000..6b7571cb
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/plugins/Routing.kt
@@ -0,0 +1,18 @@
+package tech.ydb.keycloak.proxy.plugins
+
+import io.ktor.server.application.*
+import io.ktor.server.plugins.forwardedheaders.ForwardedHeaders
+import io.ktor.server.routing.*
+import tech.ydb.keycloak.proxy.Dependencies
+
+fun Application.configureRouting(deps: Dependencies) {
+ install(ForwardedHeaders)
+
+ routing {
+ route("{...}") {
+ handle {
+ deps.controller.handle(call)
+ }
+ }
+ }
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt
new file mode 100644
index 00000000..0024a45d
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/service/ProxyService.kt
@@ -0,0 +1,120 @@
+package tech.ydb.keycloak.proxy.service
+
+import io.ktor.client.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.http.HttpHeaders.Forwarded
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import org.slf4j.LoggerFactory
+import tech.ydb.keycloak.proxy.config.ProxyConfig
+import tech.ydb.keycloak.proxy.service.ProxyResult.*
+import tech.ydb.keycloak.proxy.utils.YdbRetryableBody
+import tech.ydb.keycloak.proxy.utils.isHeader
+import tech.ydb.keycloak.proxy.utils.isHopByHop
+import kotlin.coroutines.coroutineContext
+import kotlin.random.Random
+import io.ktor.http.content.ByteArrayContent as OutgoingByteArrayContent
+
+class ProxyService(
+ private val client: HttpClient,
+ private val config: ProxyConfig,
+) {
+ private val log = LoggerFactory.getLogger(ProxyService::class.java)
+
+ suspend fun proxyRequest(
+ method: HttpMethod,
+ path: String,
+ body: ByteArray,
+ contentType: ContentType,
+ headers: Headers,
+ host: String,
+ remoteHost: String,
+ scheme: String,
+ ): ProxyResult {
+ for (attempt in 0..config.maxRetries) {
+ if (!coroutineContext.isActive) {
+ log.info("Client disconnected, stopping retries for $method $path (attempt $attempt)")
+ return ClientDisconnected
+ }
+
+ val response = try {
+ forwardToTarget(method, path, body, contentType, headers, host, remoteHost, scheme)
+ } catch (e: Exception) {
+ return Error("Proxy error: ${e.message}")
+ }
+
+ val responseBody = response.readRawBytes()
+
+ if (YdbRetryableBody.isRetryable503(response.status, responseBody)) {
+ if (retryWithBackoff(attempt, method, path)) continue
+ } else {
+ return Success(responseBody, response.headers, response.contentType(), response.status)
+ }
+ }
+
+ return Error("All ${config.maxRetries} retries exhausted for $method $path")
+ }
+
+ private suspend fun retryWithBackoff(attempt: Int, method: HttpMethod, path: String): Boolean {
+ if (attempt >= config.maxRetries) {
+ log.warn("YDB retryable 503 on $method $path, all ${config.maxRetries} retries exhausted")
+ return false
+ }
+
+ backoffDelay(attempt).let { delayMs ->
+ log.warn("YDB retryable 503 on $method $path (attempt ${attempt + 1}/${config.maxRetries}), retrying in ${delayMs}ms")
+ delay(delayMs)
+ }
+
+ return true
+ }
+
+ private suspend fun forwardToTarget(
+ method: HttpMethod,
+ path: String,
+ body: ByteArray,
+ contentType: ContentType,
+ headers: Headers,
+ host: String,
+ remoteHost: String,
+ scheme: String,
+ ): HttpResponse = client.request("${config.targetUrl}$path") {
+ this.method = method
+
+ copyHeaders(headers)
+ header(Forwarded, "for=$remoteHost;host=$host;proto=$scheme")
+
+ setBody(OutgoingByteArrayContent(body, contentType))
+ }
+
+ private fun HttpRequestBuilder.copyHeaders(headers: Headers) {
+ headers.forEach { name, values ->
+ if (!isHopByHop(name)
+ && !isHeader(name, HttpHeaders.Host)
+ && !isHeader(name, HttpHeaders.ContentType)
+ && !isHeader(name, HttpHeaders.ContentLength)
+ ) {
+ values.forEach { header(name, it) }
+ }
+ }
+ }
+
+ fun backoffDelay(attempt: Int): Long {
+ val exponential = (config.baseDelayMs shl attempt).coerceAtMost(config.maxDelayMs)
+ return Random.nextLong(0, exponential + 1)
+ }
+}
+
+sealed class ProxyResult {
+ class Success(
+ val body: ByteArray,
+ val headers: Headers,
+ val contentType: ContentType?,
+ val status: HttpStatusCode,
+ ) : ProxyResult()
+
+ data class Error(val message: String) : ProxyResult()
+ data object ClientDisconnected : ProxyResult()
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt
new file mode 100644
index 00000000..1f59cae1
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtils.kt
@@ -0,0 +1,19 @@
+package tech.ydb.keycloak.proxy.utils
+
+import io.ktor.http.*
+
+// https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1
+val HOP_BY_HOP_HEADERS = listOf(
+ HttpHeaders.Connection,
+ "Keep-Alive",
+ HttpHeaders.ProxyAuthenticate,
+ HttpHeaders.ProxyAuthorization,
+ HttpHeaders.TE,
+ HttpHeaders.Trailer,
+ HttpHeaders.TransferEncoding,
+ HttpHeaders.Upgrade,
+)
+
+fun isHopByHop(name: String): Boolean = HOP_BY_HOP_HEADERS.any { it.equals(name, ignoreCase = true) }
+
+fun isHeader(name: String, header: String): Boolean = name.equals(header, ignoreCase = true)
diff --git a/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt
new file mode 100644
index 00000000..14ccd975
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/main/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBody.kt
@@ -0,0 +1,10 @@
+package tech.ydb.keycloak.proxy.utils
+
+import io.ktor.http.HttpStatusCode
+
+object YdbRetryableBody {
+ private const val ERROR_CODE = "ydb_retryable"
+
+ fun isRetryable503(status: HttpStatusCode, body: ByteArray): Boolean =
+ status == HttpStatusCode.ServiceUnavailable && body.decodeToString().contains(ERROR_CODE)
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt
new file mode 100644
index 00000000..c8e3663e
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/controller/ProxyControllerTest.kt
@@ -0,0 +1,147 @@
+package tech.ydb.keycloak.proxy.controller
+
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import io.mockk.coEvery
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import tech.ydb.keycloak.proxy.Dependencies
+import tech.ydb.keycloak.proxy.config.ClientConfig
+import tech.ydb.keycloak.proxy.config.ProxyConfig
+import tech.ydb.keycloak.proxy.plugins.configureRouting
+import tech.ydb.keycloak.proxy.service.ProxyResult.*
+import tech.ydb.keycloak.proxy.service.ProxyService
+
+class ProxyControllerTest {
+
+ private val config = ProxyConfig(
+ targetUrl = "http://backend:8080",
+ maxRetries = 3,
+ baseDelayMs = 10,
+ maxDelayMs = 100,
+ listenPort = 9090,
+ client = ClientConfig(
+ maxConnectionsCount = 10,
+ connectTimeoutMs = 1000,
+ requestTimeoutMs = 5000,
+ ),
+ )
+
+ private val proxyService = mockk()
+
+ private fun withProxy(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
+ application {
+ configureRouting(
+ Dependencies(
+ config = config,
+ client = mockk(),
+ proxyService = proxyService,
+ controller = ProxyController(proxyService, config),
+ )
+ )
+ }
+
+ block()
+ }
+
+ @Test
+ fun forwardsSuccessResponse() = withProxy {
+ coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns
+ Success(
+ body = "hello".toByteArray(),
+ headers = headersOf(),
+ contentType = ContentType.Text.Plain,
+ status = HttpStatusCode.OK,
+ )
+
+ client.get("/test").let {
+ assertEquals(HttpStatusCode.OK, it.status)
+ assertEquals("hello", it.bodyAsText())
+ }
+ }
+
+ @Test
+ fun returnsErrorAsBadGateway() = withProxy {
+ coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns
+ Error("connection refused")
+
+ client.get("/fail").let {
+ assertEquals(HttpStatusCode.BadGateway, it.status)
+ assertEquals("connection refused", it.bodyAsText())
+ }
+ }
+
+ @Test
+ fun filtersHopByHopHeaders() = withProxy {
+ coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns
+ Success(
+ body = "ok".toByteArray(),
+ headers = headersOf(
+ HttpHeaders.Connection to listOf("keep-alive"),
+ HttpHeaders.TransferEncoding to listOf("chunked"),
+ HttpHeaders.ContentLength to listOf("999"),
+ "X-Custom" to listOf("value"),
+ ),
+ contentType = ContentType.Text.Plain,
+ status = HttpStatusCode.OK,
+ )
+
+ client.get("/headers").let {
+ assertEquals("value", it.headers["X-Custom"])
+ assertNull(it.headers[HttpHeaders.Connection])
+ assertNull(it.headers[HttpHeaders.TransferEncoding])
+ // Backend's Content-Length (999) must not leak — Ktor sets its own based on actual body size
+ assertEquals("2", it.headers[HttpHeaders.ContentLength])
+ }
+ }
+
+ @Test
+ fun rewritesLocationHeader() = withProxy {
+ coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns
+ Success(
+ body = ByteArray(0),
+ headers = headersOf(HttpHeaders.Location, "http://backend:8080/realms/master"),
+ contentType = null,
+ status = HttpStatusCode.Found,
+ )
+
+ createClient { followRedirects = false }.get("/login") {
+ header(HttpHeaders.Host, "localhost:9090")
+ }.let {
+ assertEquals(HttpStatusCode.Found, it.status)
+ assertEquals("http://localhost:9090/realms/master", it.headers[HttpHeaders.Location])
+ }
+ }
+
+ @Test
+ fun rewritesLocationHeaderWithHttpsFromForwarded() = withProxy {
+ coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns
+ Success(
+ body = ByteArray(0),
+ headers = headersOf(HttpHeaders.Location, "http://backend:8080/realms/master"),
+ contentType = null,
+ status = HttpStatusCode.Found,
+ )
+
+ createClient { followRedirects = false }.get("/login") {
+ header(HttpHeaders.Forwarded, "for=1.2.3.4;host=client.example.com;proto=https")
+ header(HttpHeaders.Host, "client.example.com")
+ }.let {
+ assertEquals(HttpStatusCode.Found, it.status)
+ assertEquals("https://client.example.com/realms/master", it.headers[HttpHeaders.Location])
+ }
+ }
+
+ @Test
+ fun clientDisconnectedReturnsNothing() = withProxy {
+ coEvery { proxyService.proxyRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns
+ ClientDisconnected
+
+ client.get("/disconnect").let {
+ assertEquals("", it.bodyAsText())
+ }
+ }
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt
new file mode 100644
index 00000000..9552cb94
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/service/ProxyServiceTest.kt
@@ -0,0 +1,227 @@
+package tech.ydb.keycloak.proxy.service
+
+import io.ktor.client.*
+import io.ktor.client.engine.mock.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import tech.ydb.keycloak.proxy.config.ProxyConfig
+
+class ProxyServiceTest {
+
+ private val config = mockk {
+ every { targetUrl } returns "http://backend:8080"
+ every { maxRetries } returns 3
+ every { baseDelayMs } returns 1L
+ every { maxDelayMs } returns 10L
+ every { listenPort } returns 9090
+ }
+
+ private fun mockClient(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): HttpClient {
+ return HttpClient(MockEngine) {
+ engine { addHandler(handler) }
+ expectSuccess = false
+ followRedirects = false
+ }
+ }
+
+ private fun proxyService(client: HttpClient) = ProxyService(client, config)
+
+ private suspend fun doRequest(service: ProxyService): ProxyResult = service.proxyRequest(
+ method = HttpMethod.Get,
+ path = "/test",
+ body = ByteArray(0),
+ contentType = ContentType.Application.Json,
+ headers = headersOf(),
+ host = "localhost:9090",
+ remoteHost = "127.0.0.1",
+ scheme = "http",
+ )
+
+ @Test
+ fun forwardsSuccessfulResponse() = runTest {
+ val service = proxyService(mockClient {
+ respond("ok", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "text/plain"))
+ })
+
+ doRequest(service).let {
+ assertInstanceOf(ProxyResult.Success::class.java, it)
+ it as ProxyResult.Success
+ assertEquals(HttpStatusCode.OK, it.status)
+ assertEquals("ok", String(it.body))
+ }
+ }
+
+ @Test
+ fun returnsErrorOnException() = runTest {
+ val service = proxyService(mockClient {
+ throw RuntimeException("connection refused")
+ })
+
+ doRequest(service).let {
+ assertInstanceOf(ProxyResult.Error::class.java, it)
+ it as ProxyResult.Error
+ assertEquals("Proxy error: connection refused", it.message)
+ }
+ }
+
+ @Test
+ fun doesNotRetryOnException() = runTest {
+ var requestCount = 0
+ val service = proxyService(mockClient {
+ requestCount++
+ throw RuntimeException("fail")
+ })
+
+ doRequest(service)
+ assertEquals(1, requestCount)
+ }
+
+ @Test
+ fun retriesOnYdbRetryable503() = runTest {
+ var requestCount = 0
+ val service = proxyService(mockClient {
+ requestCount++
+ if (requestCount <= 2) {
+ respond("""{"error": "ydb_retryable"}""", HttpStatusCode.ServiceUnavailable)
+ } else {
+ respond("ok", HttpStatusCode.OK)
+ }
+ })
+
+ doRequest(service).let {
+ assertInstanceOf(ProxyResult.Success::class.java, it)
+ it as ProxyResult.Success
+ assertEquals(HttpStatusCode.OK, it.status)
+ assertEquals("ok", String(it.body))
+ }
+ assertEquals(3, requestCount)
+ }
+
+ @Test
+ fun returnsErrorWhenRetriesExhausted() = runTest {
+ every { config.maxRetries } returns 2
+ var requestCount = 0
+ val service = proxyService(mockClient {
+ requestCount++
+ respond("""{"error": "ydb_retryable"}""", HttpStatusCode.ServiceUnavailable)
+ })
+
+ doRequest(service).let {
+ assertInstanceOf(ProxyResult.Error::class.java, it)
+ it as ProxyResult.Error
+ assertTrue(it.message.contains("retries exhausted"))
+ }
+ // 1 initial + 2 retries = 3
+ assertEquals(3, requestCount)
+
+ every { config.maxRetries } returns 3
+ }
+
+ @Test
+ fun doesNotRetryOnNonRetryable503() = runTest {
+ var requestCount = 0
+ val service = proxyService(mockClient {
+ requestCount++
+ respond("service unavailable", HttpStatusCode.ServiceUnavailable)
+ })
+
+ doRequest(service).let {
+ assertInstanceOf(ProxyResult.Success::class.java, it)
+ it as ProxyResult.Success
+ assertEquals(HttpStatusCode.ServiceUnavailable, it.status)
+ }
+ assertEquals(1, requestCount)
+ }
+
+ @Test
+ fun forwardsNon503ErrorStatusAsIs() = runTest {
+ val service = proxyService(mockClient {
+ respond("not found", HttpStatusCode.NotFound)
+ })
+
+ doRequest(service).let {
+ assertInstanceOf(ProxyResult.Success::class.java, it)
+ it as ProxyResult.Success
+ assertEquals(HttpStatusCode.NotFound, it.status)
+ assertEquals("not found", String(it.body))
+ }
+ }
+
+ @Test
+ fun forwardsRequestToCorrectUrl() = runTest {
+ var capturedUrl: String? = null
+ val service = proxyService(mockClient { request ->
+ capturedUrl = request.url.toString()
+ respond("ok", HttpStatusCode.OK)
+ })
+
+ doRequest(service)
+ assertEquals("http://backend:8080/test", capturedUrl)
+ }
+
+ @Test
+ fun setsForwardedHeader() = runTest {
+ var capturedHeaders: Headers? = null
+ val service = proxyService(mockClient { request ->
+ capturedHeaders = request.headers
+ respond("ok", HttpStatusCode.OK)
+ })
+
+ doRequest(service)
+ assertEquals("for=127.0.0.1;host=localhost:9090;proto=http", capturedHeaders!![HttpHeaders.Forwarded])
+ }
+
+ @Test
+ fun filtersHopByHopAndServiceHeaders() = runTest {
+ var capturedHeaders: Headers? = null
+ val service = proxyService(mockClient { request ->
+ capturedHeaders = request.headers
+ respond("ok", HttpStatusCode.OK)
+ })
+
+ service.proxyRequest(
+ method = HttpMethod.Get,
+ path = "/test",
+ body = ByteArray(0),
+ contentType = ContentType.Application.Json,
+ headers = headersOf(
+ HttpHeaders.Connection to listOf("keep-alive"),
+ HttpHeaders.Host to listOf("original-host"),
+ HttpHeaders.ContentType to listOf("application/json"),
+ HttpHeaders.ContentLength to listOf("0"),
+ "X-Custom" to listOf("value"),
+ HttpHeaders.Authorization to listOf("Bearer token"),
+ ),
+ host = "localhost:9090",
+ remoteHost = "127.0.0.1",
+ scheme = "http",
+ )
+
+ assertNull(capturedHeaders!![HttpHeaders.Connection])
+ assertNull(capturedHeaders!![HttpHeaders.Host])
+ assertNull(capturedHeaders!![HttpHeaders.ContentType])
+ assertNull(capturedHeaders!![HttpHeaders.ContentLength])
+ assertEquals("value", capturedHeaders!!["X-Custom"])
+ assertEquals("Bearer token", capturedHeaders!![HttpHeaders.Authorization])
+ }
+
+ @Test
+ fun backoffDelayIsWithinBounds() {
+ val service = proxyService(mockClient { respond("ok", HttpStatusCode.OK) })
+
+ repeat(100) {
+ val delay = service.backoffDelay(0)
+ assertTrue(delay in 0..config.baseDelayMs)
+ }
+
+ repeat(100) {
+ val delay = service.backoffDelay(5)
+ assertTrue(delay in 0..config.maxDelayMs)
+ }
+ }
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt
new file mode 100644
index 00000000..437459d2
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/HeaderUtilsTest.kt
@@ -0,0 +1,50 @@
+package tech.ydb.keycloak.proxy.utils
+
+import io.ktor.http.*
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class HeaderUtilsTest {
+
+ @Test
+ fun hopByHopHeadersAreDetected() {
+ assertTrue(isHopByHop("Connection"))
+ assertTrue(isHopByHop("Transfer-Encoding"))
+ assertTrue(isHopByHop("Keep-Alive"))
+ assertTrue(isHopByHop("Proxy-Authenticate"))
+ assertTrue(isHopByHop("Proxy-Authorization"))
+ assertTrue(isHopByHop("TE"))
+ assertTrue(isHopByHop("Trailer"))
+ assertTrue(isHopByHop("Upgrade"))
+ }
+
+ @Test
+ fun regularHeadersAreNotHopByHop() {
+ assertFalse(isHopByHop("Content-Type"))
+ assertFalse(isHopByHop("Authorization"))
+ assertFalse(isHopByHop("Accept"))
+ assertFalse(isHopByHop("Location"))
+ }
+
+ @Test
+ fun hopByHopIsCaseInsensitive() {
+ assertTrue(isHopByHop("connection"))
+ assertTrue(isHopByHop("CONNECTION"))
+ assertTrue(isHopByHop("transfer-encoding"))
+ assertTrue(isHopByHop("KEEP-ALIVE"))
+ }
+
+ @Test
+ fun isHeaderMatchesCaseInsensitive() {
+ assertTrue(isHeader("Content-Type", HttpHeaders.ContentType))
+ assertTrue(isHeader("content-type", HttpHeaders.ContentType))
+ assertTrue(isHeader("CONTENT-TYPE", HttpHeaders.ContentType))
+ }
+
+ @Test
+ fun isHeaderRejectsNonMatching() {
+ assertFalse(isHeader("Content-Type", HttpHeaders.ContentLength))
+ assertFalse(isHeader("Accept", HttpHeaders.Location))
+ }
+}
diff --git a/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt
new file mode 100644
index 00000000..500418f4
--- /dev/null
+++ b/keycloak-ydb-extension/retry-proxy/src/test/kotlin/tech/ydb/keycloak/proxy/utils/YdbRetryableBodyTest.kt
@@ -0,0 +1,55 @@
+package tech.ydb.keycloak.proxy.utils
+
+import io.ktor.http.*
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import tech.ydb.keycloak.proxy.utils.YdbRetryableBody.isRetryable503
+
+class YdbRetryableBodyTest {
+
+ @Test
+ fun non503Status() {
+ assertFalse(isRetryable503(HttpStatusCode.OK, "ydb_retryable".toByteArray()))
+ }
+
+ @Test
+ fun detectsMarkerInKeycloakJson() {
+ assertTrue(
+ isRetryable503(
+ HttpStatusCode.ServiceUnavailable,
+ """{"error":"ydb_retryable","error_description":"Transaction aborted, please retry"}""".toByteArray(),
+ ),
+ )
+ }
+
+ @Test
+ fun detectsMarkerInPlainText() {
+ assertTrue(isRetryable503(HttpStatusCode.ServiceUnavailable, "ydb_retryable".toByteArray()))
+ assertTrue(isRetryable503(HttpStatusCode.ServiceUnavailable, "error=ydb_retryable".toByteArray()))
+ }
+
+ @Test
+ fun detectsMarkerAsSubstring() {
+ assertTrue(
+ isRetryable503(
+ HttpStatusCode.ServiceUnavailable,
+ """{"error":"internal","error_description":"ydb_retryable"}""".toByteArray(),
+ ),
+ )
+ assertTrue(
+ isRetryable503(HttpStatusCode.ServiceUnavailable, "ydb_retryable".toByteArray()),
+ )
+ }
+
+ @Test
+ fun ignoresNonMatching503Body() {
+ assertFalse(isRetryable503(HttpStatusCode.ServiceUnavailable, "service unavailable".toByteArray()))
+ assertFalse(isRetryable503(HttpStatusCode.ServiceUnavailable, "".toByteArray()))
+ }
+
+ @Test
+ fun handlesNotOnlyStringByteArrays() {
+ assertFalse(isRetryable503(HttpStatusCode.ServiceUnavailable, byteArrayOf(0x00, 0x01, 0xFF.toByte(), 0xFE.toByte())))
+ }
+}
diff --git a/keycloak-ydb-extension/run-keycloak-with-ydb.sh b/keycloak-ydb-extension/run-keycloak-with-ydb.sh
new file mode 100755
index 00000000..6140d57d
--- /dev/null
+++ b/keycloak-ydb-extension/run-keycloak-with-ydb.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+echo "=== Building keycloak-ydb-extension (core) ==="
+mvn -f core/pom.xml clean package -q -DskipTests
+
+JAR_FILE="core/target/keycloak-ydb-extension-1.0-SNAPSHOT.jar"
+if [ ! -f "$JAR_FILE" ]; then
+ echo "Error: $JAR_FILE not found. Build may have failed."
+ exit 1
+fi
+
+mkdir -p docker/providers
+cp "$JAR_FILE" docker/providers/keycloak-ydb-extension-1.0-SNAPSHOT.jar
+echo " JAR copied to docker/providers/"
+
+echo ""
+echo "=== Building retry-proxy ==="
+mvn -f retry-proxy/pom.xml package -q -DskipTests
+echo " retry-proxy JAR built"
+
+echo ""
+echo "=== Starting Docker Compose (YDB + Keycloak + retry-proxy) ==="
+docker compose -f docker/docker-compose.yml up -d --build
+
+echo ""
+echo "Stack is starting. Wait ~30-60s for Keycloak to initialize."
+echo ""
+echo " Keycloak (via retry-proxy): http://localhost:9090"
+echo " YDB Monitoring: http://localhost:8765"
+echo " Admin credentials: admin / admin"
+echo ""
+echo "Check logs: docker compose -f docker/docker-compose.yml logs -f ydb-keycloak"
diff --git a/keycloak-ydb-extension/test/pom.xml b/keycloak-ydb-extension/test/pom.xml
new file mode 100644
index 00000000..b6ca97b6
--- /dev/null
+++ b/keycloak-ydb-extension/test/pom.xml
@@ -0,0 +1,159 @@
+
+ 4.0.0
+
+ tech.ydb
+ keycloak-ydb-extension-parent
+ 1.0-SNAPSHOT
+
+ keycloak-ydb-extension-test
+ keycloak-ydb-extension-test
+ jar
+
+
+
+
+ tech.ydb
+ keycloak-ydb-extension
+
+
+
+
+ org.keycloak
+ keycloak-quarkus-server
+ test
+
+
+ org.keycloak
+ keycloak-crypto-fips1402
+
+
+
+
+ org.keycloak
+ keycloak-services
+ test
+
+
+ org.keycloak
+ keycloak-server-spi
+ test
+
+
+ org.keycloak
+ keycloak-server-spi-private
+ test
+
+
+ org.keycloak
+ keycloak-model-jpa
+ test
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+ ${kotlin.version}
+ test
+
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ ${kotlin.version}
+ test
+
+
+
+
+ junit
+ junit
+ test
+
+
+ org.hamcrest
+ hamcrest
+ test
+
+
+
+
+ log4j
+ log4j
+ test
+
+
+ org.slf4j
+ slf4j-api
+ test
+
+
+ org.slf4j
+ slf4j-reload4j
+ test
+
+
+
+
+ org.testcontainers
+ testcontainers
+ 1.20.6
+ test
+
+
+ tech.ydb.test
+ ydb-junit4-support
+ 2.3.27
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ test-compile
+ test-compile
+
+ testCompile
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+
+
+ test-compile
+
+ test-compile
+
+
+
+ ${project.basedir}/src/test/java
+
+
+
+
+
+
+
+ maven-surefire-plugin
+
+ -Xmx1536m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED
+
+
+ Ydb,Infinispan
+ file:${project.build.directory}/test-classes/log4j.properties
+ org.jboss.logmanager.LogManager
+ log4j
+
+
+
+
+
+
diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java
new file mode 100644
index 00000000..b05fc50e
--- /dev/null
+++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/MigrationModelTest.java
@@ -0,0 +1,218 @@
+package tech.ydb.keycloak;
+
+import jakarta.persistence.EntityManager;
+import org.jboss.logging.Logger;
+import org.junit.Test;
+import org.keycloak.common.Version;
+import org.keycloak.connections.jpa.JpaConnectionProvider;
+import org.keycloak.migration.MigrationModel;
+import org.keycloak.migration.ModelVersion;
+import org.keycloak.models.*;
+import org.keycloak.models.jpa.entities.MigrationModelEntity;
+import tech.ydb.keycloak.testsuite.KeycloakModelTest;
+import tech.ydb.keycloak.testsuite.RequireProvider;
+
+import java.util.List;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+@RequireProvider(value=RealmProvider.class, only="ydb")
+@RequireProvider(value=ClientProvider.class, only="ydb")
+@RequireProvider(value=ClientScopeProvider.class, only="ydb")
+public class MigrationModelTest extends KeycloakModelTest {
+
+ private static final Logger logger = Logger.getLogger(MigrationModelTest.class);
+
+ private String realmId;
+
+ @Override
+ public void createEnvironment(KeycloakSession s) {
+ RealmModel realm = createRealm(s, "realm");
+ s.getContext().setRealm(realm);
+ realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
+ this.realmId = realm.getId();
+ }
+
+ @Override
+ public void cleanEnvironment(KeycloakSession s) {
+ RealmModel realm = s.realms().getRealm(realmId);
+ s.getContext().setRealm(realm);
+ s.realms().removeRealm(realmId);
+ }
+
+ @Test
+ public void multipleEntities() {
+ inComittedTransaction(1, (session , i) -> {
+ String currentVersion = new ModelVersion(Version.VERSION).toString();
+ EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
+
+ List entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(1));
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+
+ MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel();
+ assertThat(m.getStoredVersion(), is(currentVersion));
+ assertThat(entities.get(0).getId(), is(m.getResourcesTag()));
+
+ setTimeOffset(-60000);
+
+ try {
+ session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("6.0.0");
+ em.flush();
+
+ setTimeOffset(0);
+
+ entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(2));
+
+ logger.info("MigrationModelEntity entries: ");
+ entities.forEach(entity -> log.infof("--id: %s; %s; %s", entity.getId(), entity.getVersion(), entity.getUpdateTime()));
+
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+ assertMigrationModelEntity(entities.get(1), "6.0.0");
+
+ m = session.getProvider(DeploymentStateProvider.class).getMigrationModel();
+ assertThat(m.getStoredVersion(), is(currentVersion));
+ assertThat(entities.get(0).getId(), is(m.getResourcesTag()));
+ } finally {
+ em.remove(entities.get(1));
+ }
+
+ return null;
+ });
+ }
+
+ @Test
+ public void duplicates() {
+ inComittedTransaction(1, (session, i) -> {
+ String currentVersion = new ModelVersion(Version.VERSION).toString();
+ EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
+
+ List entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(1));
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+
+ MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel();
+ assertThat(m.getStoredVersion(), is(currentVersion));
+ assertThat(entities.get(0).getId(), is(m.getResourcesTag()));
+
+ return null;
+ });
+
+ setTimeOffset(-60000);
+ inComittedTransaction(session -> {
+ session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("26.2.4");
+ return null;
+ });
+
+ setTimeOffset(-30000);
+ inComittedTransaction(session -> {
+ session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("26.2.5");
+ return null;
+ });
+
+ setTimeOffset(0);
+
+ inComittedTransaction(1, (session, i) -> {
+ String currentVersion = new ModelVersion(Version.VERSION).toString();
+ EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
+
+ List entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(3));
+
+ logger.info("MigrationModelEntity entries: ");
+ entities.forEach(entity -> log.infof("--id: %s; %s; %s", entity.getId(), entity.getVersion(), entity.getUpdateTime()));
+
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+ assertMigrationModelEntity(entities.get(1), "26.2.5");
+ assertMigrationModelEntity(entities.get(2), "26.2.4");
+
+ return null;
+ });
+
+ setTimeOffset(-29999);
+ assertThrows(ModelDuplicateException.class, () ->
+ inComittedTransaction(session -> {
+ session.getProvider(DeploymentStateProvider.class).getMigrationModel().setStoredVersion("26.2.5");
+ return null;
+ })
+ );
+
+ inComittedTransaction(1, (session, i) -> {
+ String currentVersion = new ModelVersion(Version.VERSION).toString();
+ EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
+
+ List entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(3));
+
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+ assertMigrationModelEntity(entities.get(1), "26.2.5");
+ assertMigrationModelEntity(entities.get(2), "26.2.4");
+
+ MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel();
+ assertThat(m.getStoredVersion(), is(currentVersion));
+ assertThat(entities.get(0).getId(), is(m.getResourcesTag()));
+
+ em.remove(entities.get(1));
+ em.remove(entities.get(2));
+
+ return null;
+ });
+ }
+
+ @Test
+ public void duplicatedUpdateTime() {
+ inComittedTransaction(1, (session, i) -> {
+ String currentVersion = new ModelVersion(Version.VERSION).toString();
+ EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
+
+ List entities = getMigrationEntities(em);
+ assertThat(entities.size(), is(1));
+ assertMigrationModelEntity(entities.get(0), currentVersion);
+
+ MigrationModel m = session.getProvider(DeploymentStateProvider.class).getMigrationModel();
+ assertThat(m.getStoredVersion(), is(currentVersion));
+ assertThat(entities.get(0).getId(), is(m.getResourcesTag()));
+
+ try {
+ MigrationModelEntity mm1 = new MigrationModelEntity();
+ mm1.setId("a");
+ mm1.setUpdatedTime(0);
+ mm1.setVersion("26.0.0");
+ em.persist(mm1);
+
+ em.flush();
+
+ // Same time, everything different - testing for the constraint to be present
+ MigrationModelEntity mm2 = new MigrationModelEntity();
+ mm2.setId("b");
+ mm2.setUpdatedTime(0);
+ mm2.setVersion("26.0.1");
+ em.persist(mm2);
+
+ // added at the same time - exception thrown by the unique constraint
+ assertThrows(ModelDuplicateException.class, em::flush);
+
+ } finally {
+ em.remove(em.find(MigrationModelEntity.class, "a"));
+ }
+
+ return null;
+ });
+ }
+
+
+ private void assertMigrationModelEntity(MigrationModelEntity model, String expectedVersion) {
+ assertThat(model, notNullValue());
+ assertTrue(model.getId().matches("[\\da-z]{5}"));
+ assertThat(model.getVersion(), is(expectedVersion));
+ }
+
+ private List getMigrationEntities(EntityManager em) {
+ return em.createQuery("select m from MigrationModelEntity m ORDER BY m.updatedTime DESC", MigrationModelEntity.class).getResultList();
+ }
+}
\ No newline at end of file
diff --git a/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java
new file mode 100644
index 00000000..6d949e4c
--- /dev/null
+++ b/keycloak-ydb-extension/test/src/test/java/tech/ydb/keycloak/testsuite/Config.java
@@ -0,0 +1,167 @@
+package tech.ydb.keycloak.testsuite;
+
+import org.keycloak.Config.ConfigProvider;
+import org.keycloak.Config.Scope;
+import org.keycloak.Config.SystemPropertiesScope;
+import org.keycloak.common.util.StringPropertyReplacer;
+import org.keycloak.common.util.SystemEnvProperties;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BooleanSupplier;
+import java.util.stream.Collectors;
+
+public class Config implements ConfigProvider {
+
+ private final Map defaultProperties = new ConcurrentHashMap<>();
+ private final ThreadLocal