diff --git a/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateEntityImplementationsProcessor.java b/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateEntityImplementationsProcessor.java index 47302f24740..0d0c5fa08ed 100644 --- a/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateEntityImplementationsProcessor.java +++ b/model/build-processor/src/main/java/org/keycloak/models/map/processor/GenerateEntityImplementationsProcessor.java @@ -280,7 +280,7 @@ public class GenerateEntityImplementationsProcessor extends AbstractGenerateEnti GenerateEntityImplementations an = e.getAnnotation(GenerateEntityImplementations.class); TypeElement parentTypeElement = elements.getTypeElement((an.inherits() == null || an.inherits().isEmpty()) ? "void" : an.inherits()); if (parentTypeElement == null) { - return; + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Unable to find type " + an.inherits() + " for inherits parameter for annotation " + GenerateEntityImplementations.class.getTypeName(), e); } final List allParentMembers = elements.getAllMembers(parentTypeElement); String className = e.getQualifiedName().toString(); diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/Constants.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/Constants.java index 2e9984af371..09d734f5cbd 100644 --- a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/Constants.java +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/Constants.java @@ -39,4 +39,5 @@ public interface Constants { public static final Integer CURRENT_SCHEMA_VERSION_USER_CONSENT = 1; public static final Integer CURRENT_SCHEMA_VERSION_USER_FEDERATED_IDENTITY = 1; public static final Integer CURRENT_SCHEMA_VERSION_USER_SESSION = 1; + public static final Integer CURRENT_SCHEMA_VERSION_LOCK = 1; } diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/JpaMapStorageProviderFactory.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/JpaMapStorageProviderFactory.java index 0604c63f9f7..6fb2b439e39 100644 --- a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/JpaMapStorageProviderFactory.java +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/JpaMapStorageProviderFactory.java @@ -81,6 +81,7 @@ import org.keycloak.models.locking.GlobalLockProvider; import org.keycloak.models.map.client.MapProtocolMapperEntity; import org.keycloak.models.map.client.MapProtocolMapperEntityImpl; import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.lock.MapLockEntity; import org.keycloak.models.map.realm.entity.MapAuthenticationExecutionEntity; import org.keycloak.models.map.realm.entity.MapAuthenticationExecutionEntityImpl; import org.keycloak.models.map.realm.entity.MapAuthenticationFlowEntity; @@ -127,6 +128,8 @@ import org.keycloak.models.map.storage.jpa.event.auth.JpaAuthEventMapKeycloakTra import org.keycloak.models.map.storage.jpa.event.auth.entity.JpaAuthEventEntity; import org.keycloak.models.map.storage.jpa.group.JpaGroupMapKeycloakTransaction; import org.keycloak.models.map.storage.jpa.group.entity.JpaGroupEntity; +import org.keycloak.models.map.storage.jpa.lock.JpaLockMapKeycloakTransaction; +import org.keycloak.models.map.storage.jpa.lock.entity.JpaLockEntity; import org.keycloak.models.map.storage.jpa.loginFailure.JpaUserLoginFailureMapKeycloakTransaction; import org.keycloak.models.map.storage.jpa.loginFailure.entity.JpaUserLoginFailureEntity; import org.keycloak.models.map.storage.jpa.realm.JpaRealmMapKeycloakTransaction; @@ -224,6 +227,8 @@ public class JpaMapStorageProviderFactory implements //user/client session .constructor(JpaClientSessionEntity.class, JpaClientSessionEntity::new) .constructor(JpaUserSessionEntity.class, JpaUserSessionEntity::new) + //lock + .constructor(JpaLockEntity.class, JpaLockEntity::new) .build(); private static final Map, BiFunction> MODEL_TO_TX = new HashMap<>(); @@ -257,6 +262,8 @@ public class JpaMapStorageProviderFactory implements MODEL_TO_TX.put(UserModel.class, JpaUserMapKeycloakTransaction::new); //sessions MODEL_TO_TX.put(UserSessionModel.class, JpaUserSessionMapKeycloakTransaction::new); + //locks + MODEL_TO_TX.put(MapLockEntity.class, JpaLockMapKeycloakTransaction::new); } private boolean jtaEnabled; @@ -539,10 +546,15 @@ public class JpaMapStorageProviderFactory implements } private void update(Class modelType, Connection connection, KeycloakSession session) { - session.getProvider(GlobalLockProvider.class).withLock(modelType.getName(), lockedSession -> { - lockedSession.getProvider(MapJpaUpdaterProvider.class).update(modelType, connection, config.get("schema")); - return null; - }); + if (modelType == MapLockEntity.class) { + // as the MapLockEntity is used by the MapGlobalLockProvider itself, don't create a global lock for creating that schema + session.getProvider(MapJpaUpdaterProvider.class).update(modelType, connection, config.get("schema")); + } else { + session.getProvider(GlobalLockProvider.class).withLock(modelType.getName(), lockedSession -> { + lockedSession.getProvider(MapJpaUpdaterProvider.class).update(modelType, connection, config.get("schema")); + return null; + }); + } } @Override diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/lockservice/KeycloakLockService.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/lockservice/KeycloakLockService.java new file mode 100644 index 00000000000..14178dbe1c7 --- /dev/null +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/liquibase/lockservice/KeycloakLockService.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.storage.jpa.liquibase.lockservice; + +import liquibase.exception.DatabaseException; +import liquibase.lockservice.StandardLockService; +import liquibase.snapshot.DatabaseSnapshot; +import liquibase.snapshot.InvalidExampleException; +import liquibase.snapshot.SnapshotControl; +import liquibase.snapshot.SnapshotGeneratorFactory; +import liquibase.structure.core.PrimaryKey; +import liquibase.structure.core.Schema; +import liquibase.structure.core.Table; +import org.jboss.logging.Logger; + +/** + * Extending the Liquibase {@link StandardLockService} for situations where it failed on a H2 database. + * + * @author Alexander Schwartz + */ +public class KeycloakLockService extends StandardLockService { + + private static final Logger log = Logger.getLogger(KeycloakLockService.class); + + @Override + public int getPriority() { + return super.getPriority() + 1; + } + + @Override + protected boolean hasDatabaseChangeLogLockTable() throws DatabaseException { + boolean originalReturnValue = super.hasDatabaseChangeLogLockTable(); + if (originalReturnValue) { + /* Liquibase only checks that the table exists. On the H2 database, creation of a table with a primary key is not atomic, + and the primary key might not be visible yet. The primary key would be needed to prevent inserting the data into the table + a second time. Inserting it a second time might lead to a failure when creating the primary key, which would then roll back + the creation of the table. Therefore, at least on the H2 database, checking for the primary key is essential. + + An existing DATABASECHANGELOG might indicate that the insertion of data was completed previously. + Still, this isn't working with the DBLockTest which deletes only the DATABASECHANGELOGLOCK table. + + See https://github.com/keycloak/keycloak/issues/15487 for more information. + */ + Table lockTable = (Table) new Table().setName(database.getDatabaseChangeLogLockTableName()).setSchema( + new Schema(database.getLiquibaseCatalogName(), database.getLiquibaseSchemaName())); + SnapshotGeneratorFactory instance = SnapshotGeneratorFactory.getInstance(); + + try { + DatabaseSnapshot snapshot = instance.createSnapshot(lockTable.getSchema().toCatalogAndSchema(), database, + new SnapshotControl(database, false, Table.class, PrimaryKey.class).setWarnIfObjectNotFound(false)); + Table lockTableFromSnapshot = snapshot.get(lockTable); + if (lockTableFromSnapshot == null) { + throw new RuntimeException("DATABASECHANGELOGLOCK not found, although Liquibase claims it exists."); + } else if (lockTableFromSnapshot.getPrimaryKey() == null) { + log.warn("Primary key not found - table creation not complete yet."); + return false; + } + } catch (InvalidExampleException e) { + throw new RuntimeException(e); + } + } + return originalReturnValue; + } +} diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/JpaLockMapKeycloakTransaction.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/JpaLockMapKeycloakTransaction.java new file mode 100644 index 00000000000..53afdff8916 --- /dev/null +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/JpaLockMapKeycloakTransaction.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing Locks and + * limitations under the License. + */ +package org.keycloak.models.map.storage.jpa.lock; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.map.lock.MapLockEntity; +import org.keycloak.models.map.lock.MapLockEntityDelegate; +import org.keycloak.models.map.storage.jpa.Constants; +import org.keycloak.models.map.storage.jpa.JpaMapKeycloakTransaction; +import org.keycloak.models.map.storage.jpa.JpaModelCriteriaBuilder; +import org.keycloak.models.map.storage.jpa.JpaRootEntity; +import org.keycloak.models.map.storage.jpa.lock.delegate.JpaLockDelegateProvider; +import org.keycloak.models.map.storage.jpa.lock.entity.JpaLockEntity; + +import javax.persistence.EntityManager; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Selection; + +public class JpaLockMapKeycloakTransaction extends JpaMapKeycloakTransaction { + + @SuppressWarnings("unchecked") + public JpaLockMapKeycloakTransaction(KeycloakSession session, EntityManager em) { + super(session, JpaLockEntity.class, MapLockEntity.class, em); + } + + @Override + protected Selection selectCbConstruct(CriteriaBuilder cb, Root root) { + return cb.construct(JpaLockEntity.class, + root.get("id"), + root.get("version"), + root.get("entityVersion"), + root.get("name")); + } + + @Override + public void setEntityVersion(JpaRootEntity entity) { + entity.setEntityVersion(Constants.CURRENT_SCHEMA_VERSION_LOCK); + } + + @Override + public JpaModelCriteriaBuilder createJpaModelCriteriaBuilder() { + return new JpaLockModelCriteriaBuilder(); + } + + @Override + protected MapLockEntity mapToEntityDelegate(JpaLockEntity original) { + return new MapLockEntityDelegate(new JpaLockDelegateProvider(original, em)); + } +} diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/JpaLockModelCriteriaBuilder.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/JpaLockModelCriteriaBuilder.java new file mode 100644 index 00000000000..e1bb0cd0df1 --- /dev/null +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/JpaLockModelCriteriaBuilder.java @@ -0,0 +1,62 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.jpa.lock; + +import org.keycloak.models.map.common.StringKeyConverter.UUIDKey; +import org.keycloak.models.map.lock.MapLockEntity; +import org.keycloak.models.map.storage.CriterionNotSupportedException; +import org.keycloak.models.map.storage.jpa.JpaModelCriteriaBuilder; +import org.keycloak.models.map.storage.jpa.JpaPredicateFunction; +import org.keycloak.models.map.storage.jpa.authorization.resource.entity.JpaResourceEntity; +import org.keycloak.storage.SearchableModelField; + +import java.util.Objects; +import java.util.UUID; + +import static org.keycloak.models.map.lock.MapLockEntity.SearchableFields; + +public class JpaLockModelCriteriaBuilder extends JpaModelCriteriaBuilder { + + public JpaLockModelCriteriaBuilder() { + super(JpaLockModelCriteriaBuilder::new); + } + + private JpaLockModelCriteriaBuilder(JpaPredicateFunction predicateFunc) { + super(JpaLockModelCriteriaBuilder::new, predicateFunc); + } + + @Override + public JpaLockModelCriteriaBuilder compare(SearchableModelField modelField, Operator op, Object... value) { + switch (op) { + case EQ: + if (modelField == SearchableFields.NAME) { + + validateValue(value, modelField, op, String.class); + + return new JpaLockModelCriteriaBuilder((cb, query, root) -> + cb.equal(root.get(modelField.getName()), value[0]) + ); + } else { + throw new CriterionNotSupportedException(modelField, op); + } + + default: + throw new CriterionNotSupportedException(modelField, op); + } + } + +} diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/delegate/JpaLockDelegateProvider.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/delegate/JpaLockDelegateProvider.java new file mode 100644 index 00000000000..a468742c177 --- /dev/null +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/delegate/JpaLockDelegateProvider.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.jpa.lock.delegate; + +import org.keycloak.models.map.common.EntityField; +import org.keycloak.models.map.common.delegate.DelegateProvider; +import org.keycloak.models.map.lock.MapLockEntity; +import org.keycloak.models.map.lock.MapLockEntityFields; +import org.keycloak.models.map.storage.jpa.JpaDelegateProvider; +import org.keycloak.models.map.storage.jpa.lock.entity.JpaLockEntity; + +import javax.persistence.EntityManager; +import java.util.UUID; + +/** + * A {@link DelegateProvider} implementation for {@link JpaLockEntity}. + * + * @author Stefan Guilhen + */ +public class JpaLockDelegateProvider extends JpaDelegateProvider implements DelegateProvider { + + private final EntityManager em; + + public JpaLockDelegateProvider(final JpaLockEntity delegate, final EntityManager em) { + super(delegate); + this.em = em; + } + + @Override + public MapLockEntity getDelegate(boolean isRead, Enum> field, Object... parameters) { + if (getDelegate().isMetadataInitialized()) return getDelegate(); + if (isRead) { + if (field instanceof MapLockEntityFields) { + switch ((MapLockEntityFields) field) { + case ID: + case NAME: + return getDelegate(); + + default: + setDelegate(em.find(JpaLockEntity.class, UUID.fromString(getDelegate().getId()))); + } + } else { + throw new IllegalStateException("Not a valid lock field: " + field); + } + } else { + setDelegate(em.find(JpaLockEntity.class, UUID.fromString(getDelegate().getId()))); + } + return getDelegate(); + } + +} diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/entity/JpaLockEntity.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/entity/JpaLockEntity.java new file mode 100644 index 00000000000..8e09260cf3f --- /dev/null +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/entity/JpaLockEntity.java @@ -0,0 +1,169 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.jpa.lock.entity; + +import java.util.Objects; +import java.util.UUID; +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.persistence.Version; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; +import org.hibernate.annotations.TypeDefs; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.UuidValidator; +import org.keycloak.models.map.storage.jpa.JpaRootVersionedEntity; +import org.keycloak.models.map.storage.jpa.hibernate.jsonb.JsonbType; + +import static org.keycloak.models.map.lock.MapLockEntity.AbstractLockEntity; +import static org.keycloak.models.map.storage.jpa.Constants.CURRENT_SCHEMA_VERSION_GROUP; + + +/** + * There are some fields marked by {@code @Column(insertable = false, updatable = false)}. + * Those fields are automatically generated by database from json field, + * therefore marked as non-insertable and non-updatable to instruct hibernate. + */ +@Entity +@Table(name = "kc_lock", uniqueConstraints = {@UniqueConstraint(columnNames = {"name"})}) +@TypeDefs({@TypeDef(name = "jsonb", typeClass = JsonbType.class)}) +public class JpaLockEntity extends AbstractLockEntity implements JpaRootVersionedEntity { + + @Id + @Column + private UUID id; + + //used for implicit optimistic locking + @Version + @Column + private int version; + + @Type(type = "jsonb") + @Column(columnDefinition = "jsonb") + private final JpaLockMetadata metadata; + + @Column(insertable = false, updatable = false) + @Basic(fetch = FetchType.LAZY) + private Integer entityVersion; + + @Column(insertable = false, updatable = false) + @Basic(fetch = FetchType.LAZY) + private String name; + + /** + * No-argument constructor, used by hibernate to instantiate entities. + */ + public JpaLockEntity() { + this.metadata = new JpaLockMetadata(); + } + + public JpaLockEntity(DeepCloner cloner) { + this.metadata = new JpaLockMetadata(cloner); + } + + public JpaLockEntity(final UUID id, final int version, final Integer entityVersion, String name) { + this.id = id; + this.version = version; + this.entityVersion = entityVersion; + this.name = name; + this.metadata = null; + } + + public boolean isMetadataInitialized() { + return metadata != null; + } + + @Override + public Integer getEntityVersion() { + if (isMetadataInitialized()) return metadata.getEntityVersion(); + return entityVersion; + } + + @Override + public Integer getCurrentSchemaVersion() { + return CURRENT_SCHEMA_VERSION_GROUP; + } + + @Override + public void setEntityVersion(Integer entityVersion) { + metadata.setEntityVersion(entityVersion); + } + + @Override + public int getVersion() { + return version; + } + + @Override + public String getId() { + return id == null ? null : id.toString(); + } + + @Override + public void setId(String id) { + String validatedId = UuidValidator.validateAndConvert(id); + this.id = UUID.fromString(validatedId); + } + + @Override + public String getName() { + if (isMetadataInitialized()) return metadata.getName(); + return name; + } + + @Override + public void setName(String name) { + metadata.setName(name); + } + + @Override + public String getKeycloakInstanceIdentifier() { + return metadata.getKeycloakInstanceIdentifier(); + } + + @Override + public void setKeycloakInstanceIdentifier(String keycloakInstanceIdentifier) { + metadata.setKeycloakInstanceIdentifier(keycloakInstanceIdentifier); + } + + @Override + public Long getTimeAcquired() { + return metadata.getTimeAcquired(); + } + + @Override + public void setTimeAcquired(Long timeAcquired) { + metadata.setTimeAcquired(timeAcquired); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof JpaLockEntity)) return false; + return Objects.equals(getId(), ((JpaLockEntity) obj).getId()); + } +} diff --git a/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/entity/JpaLockMetadata.java b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/entity/JpaLockMetadata.java new file mode 100644 index 00000000000..e6568c3a90c --- /dev/null +++ b/model/map-jpa/src/main/java/org/keycloak/models/map/storage/jpa/lock/entity/JpaLockMetadata.java @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage.jpa.lock.entity; + +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.lock.MapLockEntityImpl; + +import java.io.Serializable; + +public class JpaLockMetadata extends MapLockEntityImpl implements Serializable { + + public JpaLockMetadata(DeepCloner cloner) { + super(cloner); + } + + public JpaLockMetadata() { + super(DeepCloner.DUMB_CLONER); + } + + private Integer entityVersion; + + public Integer getEntityVersion() { + return entityVersion; + } + + public void setEntityVersion(Integer entityVersion) { + this.entityVersion = entityVersion; + } + +} diff --git a/model/map-jpa/src/main/resources/META-INF/jpa-aggregate-changelog.xml b/model/map-jpa/src/main/resources/META-INF/jpa-aggregate-changelog.xml index 6b3ce97cf54..101370d0442 100644 --- a/model/map-jpa/src/main/resources/META-INF/jpa-aggregate-changelog.xml +++ b/model/map-jpa/src/main/resources/META-INF/jpa-aggregate-changelog.xml @@ -25,6 +25,7 @@ limitations under the License. + diff --git a/model/map-jpa/src/main/resources/META-INF/jpa-locks-changelog.xml b/model/map-jpa/src/main/resources/META-INF/jpa-locks-changelog.xml new file mode 100644 index 00000000000..93384ed18b2 --- /dev/null +++ b/model/map-jpa/src/main/resources/META-INF/jpa-locks-changelog.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/model/map-jpa/src/main/resources/META-INF/locks/jpa-locks-changelog-1.xml b/model/map-jpa/src/main/resources/META-INF/locks/jpa-locks-changelog-1.xml new file mode 100644 index 00000000000..12d5921e819 --- /dev/null +++ b/model/map-jpa/src/main/resources/META-INF/locks/jpa-locks-changelog-1.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/map-jpa/src/main/resources/META-INF/services/liquibase.lockservice.LockService b/model/map-jpa/src/main/resources/META-INF/services/liquibase.lockservice.LockService new file mode 100644 index 00000000000..1795f500c35 --- /dev/null +++ b/model/map-jpa/src/main/resources/META-INF/services/liquibase.lockservice.LockService @@ -0,0 +1,20 @@ +# +# Copyright 2023 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# This is only used when running via Undertow and using the DefaultLiquibaseConnectionProvider. +# When using Quarkus, the class is explicitly named inside the LiquibaseProcessor. +org.keycloak.models.map.storage.jpa.liquibase.lockservice.KeycloakLockService diff --git a/model/map-jpa/src/main/resources/default-map-jpa-persistence.xml b/model/map-jpa/src/main/resources/default-map-jpa-persistence.xml index 3884e4e0427..976d247b8eb 100644 --- a/model/map-jpa/src/main/resources/default-map-jpa-persistence.xml +++ b/model/map-jpa/src/main/resources/default-map-jpa-persistence.xml @@ -65,5 +65,7 @@ org.keycloak.models.map.storage.jpa.user.entity.JpaUserAttributeEntity org.keycloak.models.map.storage.jpa.user.entity.JpaUserConsentEntity org.keycloak.models.map.storage.jpa.user.entity.JpaUserFederatedIdentityEntity + + org.keycloak.models.map.storage.jpa.lock.entity.JpaLockEntity diff --git a/model/map/src/main/java/org/keycloak/models/map/lock/MapGlobalLockProvider.java b/model/map/src/main/java/org/keycloak/models/map/lock/MapGlobalLockProvider.java new file mode 100644 index 00000000000..789467ace16 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/lock/MapGlobalLockProvider.java @@ -0,0 +1,194 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.lock; + +import org.keycloak.common.util.Retry; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionTaskWithResult; +import org.keycloak.models.locking.GlobalLockProvider; +import org.keycloak.models.locking.LockAcquiringTimeoutException; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.storage.MapStorage; +import org.keycloak.models.map.storage.ModelCriteriaBuilder; +import org.keycloak.models.map.storage.QueryParameters; +import org.keycloak.models.map.storage.criteria.DefaultModelCriteria; +import org.keycloak.models.utils.KeycloakModelUtils; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.function.Supplier; + +import static org.keycloak.models.map.storage.criteria.DefaultModelCriteria.criteria; + + +/** + * Implementing a {@link GlobalLockProvider} based on a map storage. + * This requires the map store to support the entity type {@link MapLockEntity}. One of the stores which supports + * this is the JPA Map Store. The store needs to support the uniqueness of entries in the lock area, see + * {@link #lock(String)} for details. + * + * @author Alexander Schwartz + */ +public class MapGlobalLockProvider implements GlobalLockProvider { + + private final KeycloakSession session; + private final long defaultTimeoutMilliseconds; + private MapKeycloakTransaction tx; + + /** + * The lockStoreSupplier allows the store to be initialized lazily and only when needed: As this provider is initialized + * for both the outer and the inner transactions, and the store is needed only for the inner transactions. + */ + private final Supplier> lockStoreSupplier; + + public MapGlobalLockProvider(KeycloakSession session, long defaultTimeoutMilliseconds, Supplier> lockStoreSupplier) { + this.defaultTimeoutMilliseconds = defaultTimeoutMilliseconds; + this.session = session; + this.lockStoreSupplier = lockStoreSupplier; + } + + @Override + public V withLock(String lockName, Duration timeToWaitForLock, KeycloakSessionTaskWithResult task) throws LockAcquiringTimeoutException { + MapLockEntity[] lockEntity = {null}; + try { + if (timeToWaitForLock == null) { + // Set default timeout if null provided + timeToWaitForLock = Duration.ofMillis(defaultTimeoutMilliseconds); + } + String[] keycloakInstanceIdentifier = {null}; + Instant[] timeWhenAcquired = {null}; + try { + Retry.executeWithBackoff(i -> lockEntity[0] = KeycloakModelUtils.runJobInTransactionWithResult(this.session.getKeycloakSessionFactory(), + innerSession -> { + MapGlobalLockProvider provider = (MapGlobalLockProvider) innerSession.getProvider(GlobalLockProvider.class); + // even if the call to provider.lock() succeeds, due to concurrency one can only be sure after a commit that all DB constraints have been met + return provider.lock(lockName); + }), (iteration, t) -> { + if (t instanceof LockAcquiringTimeoutException) { + LockAcquiringTimeoutException ex = (LockAcquiringTimeoutException) t; + keycloakInstanceIdentifier[0] = ex.getKeycloakInstanceIdentifier(); + timeWhenAcquired[0] = ex.getTimeWhenAcquired(); + } + }, timeToWaitForLock, 500); + } catch (RuntimeException ex) { + if (!(ex instanceof LockAcquiringTimeoutException)) { + throw new LockAcquiringTimeoutException(lockName, keycloakInstanceIdentifier[0], timeWhenAcquired[0], ex); + } + throw ex; + } + return KeycloakModelUtils.runJobInTransactionWithResult(this.session.getKeycloakSessionFactory(), task); + } finally { + if (lockEntity[0] != null) { + KeycloakModelUtils.runJobInTransaction(this.session.getKeycloakSessionFactory(), innerSession -> { + MapGlobalLockProvider provider = (MapGlobalLockProvider) innerSession.getProvider(GlobalLockProvider.class); + provider.unlock(lockEntity[0]); + }); + } + } + } + + @Override + public void forceReleaseAllLocks() { + KeycloakModelUtils.runJobInTransaction(this.session.getKeycloakSessionFactory(), innerSession -> { + MapGlobalLockProvider provider = (MapGlobalLockProvider) innerSession.getProvider(GlobalLockProvider.class); + provider.releaseAllLocks(); + }); + } + + @Override + public void close() { + } + + private void prepareTx() { + if (tx == null) { + this.tx = lockStoreSupplier.get().createTransaction(session); + session.getTransactionManager().enlist(tx); + } + } + + /** + * Create a {@link MapLockEntity} for the provided lockName. + * The underlying store must ensure that a lock with the given name can be created only once in the store. + * This constraint needs to be checked either at the time of creation, or at the latest when the transaction + * is committed. If such a constraint violation is detected at the time of the transaction commit, it should + * throw an exception and the transaction should roll back. + *

+ * The JPA Map Store implements this with a unique index, which is checked by the database both at the time of + * insertion and at the time the transaction is committed. + */ + private MapLockEntity lock(String lockName) { + prepareTx(); + DefaultModelCriteria mcb = criteria(); + mcb = mcb.compare(MapLockEntity.SearchableFields.NAME, ModelCriteriaBuilder.Operator.EQ, lockName); + Optional entry = tx.read(QueryParameters.withCriteria(mcb)).findFirst(); + + if (entry.isEmpty()) { + MapLockEntity entity = DeepCloner.DUMB_CLONER.newInstance(MapLockEntity.class); + entity.setName(lockName); + entity.setKeycloakInstanceIdentifier(getKeycloakInstanceIdentifier()); + entity.setTimeAcquired(Time.currentTimeMillis()); + return tx.create(entity); + } else { + throw new LockAcquiringTimeoutException(lockName, entry.get().getKeycloakInstanceIdentifier(), Instant.ofEpochMilli(entry.get().getTimeAcquired())); + } + } + + /** + * Unlock the previously created lock. + * Will fail if the lock doesn't exist, or has a different owner. + */ + private void unlock(MapLockEntity lockEntity) { + prepareTx(); + MapLockEntity readLockEntity = tx.read(lockEntity.getId()); + + if (readLockEntity == null) { + throw new RuntimeException("didn't find lock - someone else unlocked it?"); + } else if (!lockEntity.isLockUnchanged(readLockEntity)) { + // this case is there for stores which might re-use IDs or derive it from the name of the entity (like the file store map store does in some cases). + throw new RuntimeException(String.format("Lock owned by different instance: Lock [%s] acquired by keycloak instance [%s] at the time [%s]", + readLockEntity.getName(), readLockEntity.getKeycloakInstanceIdentifier(), readLockEntity.getTimeAcquired())); + } else { + tx.delete(readLockEntity.getId()); + } + } + + private void releaseAllLocks() { + prepareTx(); + DefaultModelCriteria mcb = criteria(); + tx.delete(QueryParameters.withCriteria(mcb)); + } + + private static String getKeycloakInstanceIdentifier() { + long pid = ProcessHandle.current().pid(); + String hostname; + try { + hostname = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + hostname = "unknown-host"; + } + + String threadName = Thread.currentThread().getName(); + return threadName + "#" + pid + "@" + hostname; + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/lock/MapGlobalLockProviderFactory.java b/model/map/src/main/java/org/keycloak/models/map/lock/MapGlobalLockProviderFactory.java new file mode 100644 index 00000000000..04e4020bfbd --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/lock/MapGlobalLockProviderFactory.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.lock; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.locking.GlobalLockProvider; +import org.keycloak.models.locking.GlobalLockProviderFactory; +import org.keycloak.models.map.common.AbstractMapProviderFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +import java.util.List; + +/** + * Factory to create a GlobalLockProvider backed by a Map store. + * + * @author Alexander Schwartz + */ +public class MapGlobalLockProviderFactory extends AbstractMapProviderFactory implements GlobalLockProviderFactory, EnvironmentDependentProviderFactory { + + public static final String DEFAULT_TIMEOUT_MILLISECONDS = "defaultTimeoutMilliseconds"; + public static final long DEFAULT_VALUE = 5000L; + private long defaultTimeoutMilliseconds; + + public MapGlobalLockProviderFactory() { + super(MapLockEntity.class, GlobalLockProvider.class); + } + + @Override + public MapGlobalLockProvider createNew(KeycloakSession session) { + return new MapGlobalLockProvider(session, defaultTimeoutMilliseconds, () -> getStorage(session)); + } + + @Override + public void init(Config.Scope config) { + super.init(config); + defaultTimeoutMilliseconds = config.getLong(DEFAULT_TIMEOUT_MILLISECONDS, DEFAULT_VALUE); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.MAP_STORAGE); + } + + @Override + public String getHelpText() { + return "Lock provider"; + } + + @Override + public List getConfigMetadata() { + return ProviderConfigurationBuilder.create() + .property() + .name(DEFAULT_TIMEOUT_MILLISECONDS) + .type("int") + .helpText("Default timeout when waiting for a lock") + .defaultValue(DEFAULT_VALUE) + .add() + + .build(); + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/lock/MapLockEntity.java b/model/map/src/main/java/org/keycloak/models/map/lock/MapLockEntity.java new file mode 100644 index 00000000000..1016d98149e --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/lock/MapLockEntity.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.map.lock; + +import org.keycloak.models.map.annotations.GenerateEntityImplementations; +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.DeepCloner; +import org.keycloak.models.map.common.UpdatableEntity; +import org.keycloak.storage.SearchableModelField; + +import java.util.Objects; + +/** + * Entity to hold locks needed for the {@link MapGlobalLockProvider}. + * + * @author Alexander Schwartz + */ +@GenerateEntityImplementations( + inherits = "org.keycloak.models.map.lock.MapLockEntity.AbstractLockEntity" +) +@DeepCloner.Root +public interface MapLockEntity extends UpdatableEntity, AbstractEntity { + + public static class SearchableFields { + public static final SearchableModelField NAME = new SearchableModelField<>("name", String.class); + } + + public abstract class AbstractLockEntity extends Impl implements MapLockEntity { + + private String id; + + @Override + public String getId() { + return this.id; + } + + @Override + public void setId(String id) { + if (this.id != null) throw new IllegalStateException("Id cannot be changed"); + this.id = id; + this.updated |= id != null; + } + + } + + String getName(); + void setName(String name); + + String getKeycloakInstanceIdentifier(); + void setKeycloakInstanceIdentifier(String keycloakInstanceIdentifier); + + Long getTimeAcquired(); + void setTimeAcquired(Long timeAcquired); + + default boolean isLockUnchanged(MapLockEntity otherMapLock) { + return Objects.equals(getKeycloakInstanceIdentifier(), otherMapLock.getKeycloakInstanceIdentifier()) && + Objects.equals(getTimeAcquired(), otherMapLock.getTimeAcquired()) && + Objects.equals(getName(), otherMapLock.getName()) && + Objects.equals(getId(), otherMapLock.getId()); + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java b/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java index ed7bd766cff..53b2664af36 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/ModelEntityUtil.java @@ -32,6 +32,7 @@ import org.keycloak.models.RoleModel; import org.keycloak.models.UserLoginFailureModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.models.map.lock.MapLockEntity; import org.keycloak.models.map.singleUseObject.MapSingleUseObjectEntity; import org.keycloak.models.map.authSession.MapRootAuthenticationSessionEntity; import org.keycloak.models.map.authorization.entity.MapPermissionTicketEntity; @@ -96,6 +97,9 @@ public class ModelEntityUtil { // events MODEL_TO_NAME.put(AdminEvent.class, "admin-events"); MODEL_TO_NAME.put(Event.class, "auth-events"); + + // locks + MODEL_TO_NAME.put(MapLockEntity.class, "locks"); } private static final Map> NAME_TO_MODEL = MODEL_TO_NAME.entrySet().stream().collect(Collectors.toUnmodifiableMap(Entry::getValue, Entry::getKey)); diff --git a/model/map/src/main/resources/META-INF/services/org.keycloak.models.locking.GlobalLockProviderFactory b/model/map/src/main/resources/META-INF/services/org.keycloak.models.locking.GlobalLockProviderFactory new file mode 100644 index 00000000000..39c77b4f279 --- /dev/null +++ b/model/map/src/main/resources/META-INF/services/org.keycloak.models.locking.GlobalLockProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2023 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.keycloak.models.map.lock.MapGlobalLockProviderFactory diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/LiquibaseProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/LiquibaseProcessor.java index bb0ae8fcd1c..ad0266bf85d 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/LiquibaseProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/LiquibaseProcessor.java @@ -12,7 +12,6 @@ import java.util.Set; import io.quarkus.agroal.spi.JdbcDataSourceBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; -import liquibase.lockservice.StandardLockService; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; @@ -29,6 +28,7 @@ import liquibase.parser.ChangeLogParser; import liquibase.parser.core.xml.XMLChangeLogSAXParser; import liquibase.servicelocator.LiquibaseService; import liquibase.sqlgenerator.SqlGenerator; +import org.keycloak.models.map.storage.jpa.liquibase.lockservice.KeycloakLockService; import org.keycloak.quarkus.runtime.KeycloakRecorder; import static org.keycloak.config.StorageOptions.STORAGE; @@ -81,7 +81,7 @@ class LiquibaseProcessor { } if (StorageOptions.StorageType.jpa.name().equals(getOptionalValue(NS_KEYCLOAK_PREFIX.concat(STORAGE.getKey())).orElse(null))) { - services.put(LockService.class.getName(), Collections.singletonList(StandardLockService.class.getName())); + services.put(LockService.class.getName(), Collections.singletonList(KeycloakLockService.class.getName())); } else { services.put(LockService.class.getName(), Collections.singletonList(DummyLockService.class.getName())); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/StoragePropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/StoragePropertyMappers.java index b6059576df1..73a5c671b17 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/StoragePropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/StoragePropertyMappers.java @@ -203,6 +203,12 @@ final class StoragePropertyMappers { .transformer(StoragePropertyMappers::getGlobalLockProvider) .paramLabel("type") .build(), + fromOption(StorageOptions.STORAGE_GLOBAL_LOCK_PROVIDER) + .to("kc.spi-global-lock-map-storage-provider") + .mapFrom("storage") + .transformer(StoragePropertyMappers::resolveMapStorageProvider) + .paramLabel("type") + .build(), fromOption(StorageOptions.STORAGE_CACHE_REALM_ENABLED) .to("kc.spi-realm-cache-default-enabled") .mapFrom("storage") @@ -339,10 +345,15 @@ final class StoragePropertyMappers { private static Optional getGlobalLockProvider(Optional storage, ConfigSourceInterceptorContext context) { try { if (storage.isPresent()) { - return of(storage.map(StorageType::valueOf) - .filter(type -> type.equals(StorageType.hotrod)) - .map(StorageType::getProvider) - .orElse("none")); + StorageType storageType = StorageType.valueOf(storage.get()); + switch (storageType) { + case hotrod: + return Optional.of(storageType.getProvider()); + case jpa: + return Optional.of("map"); + default: + return Optional.of("none"); + } } } catch (IllegalArgumentException iae) { throw new IllegalArgumentException("Invalid storage provider: " + storage.orElse(null), iae); diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/storage/map/JPAStoreDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/storage/map/JPAStoreDistTest.java index 3cd1cde0406..013b8bae7ee 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/storage/map/JPAStoreDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/storage/map/JPAStoreDistTest.java @@ -34,7 +34,7 @@ public class JPAStoreDistTest { void testSuccessful(LaunchResult result) { CLIResult cliResult = (CLIResult) result; cliResult.assertMessage("Experimental features enabled: map-storage"); - cliResult.assertMessage("[org.keycloak.models.map.storage.jpa.liquibase.updater.MapJpaLiquibaseUpdaterProvider] (main) Initializing database schema. Using changelog META-INF/jpa-realms-changelog.xml"); + cliResult.assertMessage("[org.keycloak.models.map.storage.jpa.liquibase.updater.MapJpaLiquibaseUpdaterProvider] (main) Initializing database schema. Using changelog META-INF"); cliResult.assertStarted(); } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/locking/LockAcquiringTimeoutException.java b/server-spi-private/src/main/java/org/keycloak/models/locking/LockAcquiringTimeoutException.java index 1f7ef7db26d..31f783e2100 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/locking/LockAcquiringTimeoutException.java +++ b/server-spi-private/src/main/java/org/keycloak/models/locking/LockAcquiringTimeoutException.java @@ -24,6 +24,10 @@ import java.time.Instant; */ public final class LockAcquiringTimeoutException extends RuntimeException { + private final String lockName; + private final String keycloakInstanceIdentifier; + private final Instant timeWhenAcquired; + /** * * @param lockName Identifier of a lock whose acquiring was unsuccessful. @@ -31,7 +35,10 @@ public final class LockAcquiringTimeoutException extends RuntimeException { * @param timeWhenAcquired Time instant when the lock held by {@code keycloakInstanceIdentifier} was acquired. */ public LockAcquiringTimeoutException(String lockName, String keycloakInstanceIdentifier, Instant timeWhenAcquired) { - super(String.format("Lock [%s] already acquired by keycloak instance [%s] at the time [%s]", lockName, keycloakInstanceIdentifier, timeWhenAcquired.toString())); + super(String.format("Lock [%s] already acquired by keycloak instance [%s] at the time [%s]", lockName, keycloakInstanceIdentifier, timeWhenAcquired)); + this.lockName = lockName; + this.keycloakInstanceIdentifier = keycloakInstanceIdentifier; + this.timeWhenAcquired = timeWhenAcquired; } /** @@ -42,6 +49,21 @@ public final class LockAcquiringTimeoutException extends RuntimeException { * @param cause The cause. */ public LockAcquiringTimeoutException(String lockName, String keycloakInstanceIdentifier, Instant timeWhenAcquired, Throwable cause) { - super(String.format("Lock [%s] already acquired by keycloak instance [%s] at the time [%s]", lockName, keycloakInstanceIdentifier, timeWhenAcquired.toString()), cause); + super(String.format("Lock [%s] already acquired by keycloak instance [%s] at the time [%s]", lockName, keycloakInstanceIdentifier, timeWhenAcquired), cause); + this.lockName = lockName; + this.keycloakInstanceIdentifier = keycloakInstanceIdentifier; + this.timeWhenAcquired = timeWhenAcquired; + } + + public String getLockName() { + return lockName; + } + + public String getKeycloakInstanceIdentifier() { + return keycloakInstanceIdentifier; + } + + public Instant getTimeWhenAcquired() { + return timeWhenAcquired; } } diff --git a/testsuite/integration-arquillian/tests/base/pom.xml b/testsuite/integration-arquillian/tests/base/pom.xml index dd6df5e0089..05af84f3aed 100644 --- a/testsuite/integration-arquillian/tests/base/pom.xml +++ b/testsuite/integration-arquillian/tests/base/pom.xml @@ -910,7 +910,8 @@ jpa jpa jpa - none + map + jpa @@ -1092,7 +1093,8 @@ jpa jpa jpa - none + map + jpa diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json index bfa01de8693..ee87847d225 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json @@ -52,7 +52,12 @@ }, "globalLock": { - "provider": "${keycloak.globalLock.provider:dblock}" + "provider": "${keycloak.globalLock.provider:dblock}", + "map": { + "storage": { + "provider": "${keycloak.lock.map.storage.provider,keycloak.mapStorage.provider.default:concurrenthashmap}" + } + } }, "realm": { diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorage.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorage.java index 6dc555567f8..e8f7a3d3f5c 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorage.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorage.java @@ -25,6 +25,7 @@ import org.keycloak.models.DeploymentStateSpi; import org.keycloak.models.SingleUseObjectSpi; import org.keycloak.models.UserLoginFailureSpi; import org.keycloak.models.UserSessionSpi; +import org.keycloak.models.locking.GlobalLockProviderSpi; import org.keycloak.models.map.authSession.MapRootAuthenticationSessionProviderFactory; import org.keycloak.models.map.authorization.MapAuthorizationStoreFactory; import org.keycloak.models.map.client.MapClientProviderFactory; @@ -39,6 +40,7 @@ import org.keycloak.models.map.role.MapRoleProviderFactory; import org.keycloak.models.map.singleUseObject.MapSingleUseObjectProviderFactory; import org.keycloak.models.map.storage.MapStorageSpi; import org.keycloak.models.map.storage.chm.ConcurrentHashMapStorageProviderFactory; +import org.keycloak.models.map.lock.MapGlobalLockProviderFactory; import org.keycloak.models.map.storage.jpa.JpaMapStorageProviderFactory; import org.keycloak.models.map.storage.jpa.liquibase.connection.MapLiquibaseConnectionProviderFactory; import org.keycloak.models.map.storage.jpa.liquibase.connection.MapLiquibaseConnectionSpi; @@ -79,6 +81,7 @@ public class JpaMapStorage extends KeycloakModelParameters { .add(JpaMapStorageProviderFactory.class) .add(MapJpaUpdaterProviderFactory.class) .add(MapLiquibaseConnectionProviderFactory.class) + .add(MapGlobalLockProviderFactory.class) .build(); public JpaMapStorage() { @@ -114,7 +117,9 @@ public class JpaMapStorage extends KeycloakModelParameters { .spi("publicKeyStorage").provider(MapPublicKeyStorageProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) .spi(UserSessionSpi.NAME).provider(MapUserSessionProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID) .spi(EventStoreSpi.NAME).provider(MapEventStoreProviderFactory.PROVIDER_ID) .config("storage-admin-events.provider", JpaMapStorageProviderFactory.PROVIDER_ID) - .config("storage-auth-events.provider", JpaMapStorageProviderFactory.PROVIDER_ID); + .config("storage-auth-events.provider", JpaMapStorageProviderFactory.PROVIDER_ID) + .spi(GlobalLockProviderSpi.GLOBAL_LOCK) .config("provider", MapGlobalLockProviderFactory.PROVIDER_ID) + .spi(GlobalLockProviderSpi.GLOBAL_LOCK).provider(MapGlobalLockProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID); } @Override diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorageCockroachdb.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorageCockroachdb.java index cc85ffc90a7..859477a7b3d 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorageCockroachdb.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/JpaMapStorageCockroachdb.java @@ -17,13 +17,13 @@ package org.keycloak.testsuite.model.parameters; import com.google.common.collect.ImmutableSet; -import org.jboss.logging.Logger; import org.keycloak.authorization.store.StoreFactorySpi; import org.keycloak.events.EventStoreSpi; import org.keycloak.models.DeploymentStateSpi; import org.keycloak.models.SingleUseObjectSpi; import org.keycloak.models.UserLoginFailureSpi; import org.keycloak.models.UserSessionSpi; +import org.keycloak.models.locking.GlobalLockProviderSpi; import org.keycloak.models.map.authSession.MapRootAuthenticationSessionProviderFactory; import org.keycloak.models.map.authorization.MapAuthorizationStoreFactory; import org.keycloak.models.map.client.MapClientProviderFactory; @@ -32,6 +32,7 @@ import org.keycloak.models.map.deploymentState.MapDeploymentStateProviderFactory import org.keycloak.models.map.events.MapEventStoreProviderFactory; import org.keycloak.models.map.group.MapGroupProviderFactory; import org.keycloak.models.map.keys.MapPublicKeyStorageProviderFactory; +import org.keycloak.models.map.lock.MapGlobalLockProviderFactory; import org.keycloak.models.map.loginFailure.MapUserLoginFailureProviderFactory; import org.keycloak.models.map.realm.MapRealmProviderFactory; import org.keycloak.models.map.role.MapRoleProviderFactory; @@ -60,8 +61,6 @@ import static org.keycloak.testsuite.model.transaction.StorageTransactionTest.LO public class JpaMapStorageCockroachdb extends KeycloakModelParameters { - private static final Logger LOG = Logger.getLogger(JpaMapStorageCockroachdb.class.getName()); - private static final Boolean START_CONTAINER = Boolean.valueOf(System.getProperty("cockroachdb.start-container", "true")); private static final String COCKROACHDB_DOCKER_IMAGE_NAME = System.getProperty("keycloak.map.storage.cockroachdb.docker.image", "cockroachdb/cockroach:v22.1.0"); private static final CockroachContainer COCKROACHDB_CONTAINER = new CockroachContainer(DockerImageName.parse(COCKROACHDB_DOCKER_IMAGE_NAME).asCompatibleSubstituteFor("cockroachdb")); @@ -80,6 +79,7 @@ public class JpaMapStorageCockroachdb extends KeycloakModelParameters { .add(JpaMapStorageProviderFactory.class) .add(MapJpaUpdaterProviderFactory.class) .add(MapLiquibaseConnectionProviderFactory.class) + .add(MapGlobalLockProviderFactory.class) .build(); public JpaMapStorageCockroachdb() { @@ -115,7 +115,9 @@ public class JpaMapStorageCockroachdb extends KeycloakModelParameters { .spi("publicKeyStorage").provider(MapPublicKeyStorageProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, ConcurrentHashMapStorageProviderFactory.PROVIDER_ID) .spi(UserSessionSpi.NAME).provider(MapUserSessionProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID) .spi(EventStoreSpi.NAME).provider(MapEventStoreProviderFactory.PROVIDER_ID) .config("storage-admin-events.provider", JpaMapStorageProviderFactory.PROVIDER_ID) - .config("storage-auth-events.provider", JpaMapStorageProviderFactory.PROVIDER_ID); + .config("storage-auth-events.provider", JpaMapStorageProviderFactory.PROVIDER_ID) + .spi(GlobalLockProviderSpi.GLOBAL_LOCK) .config("provider", MapGlobalLockProviderFactory.PROVIDER_ID) + .spi(GlobalLockProviderSpi.GLOBAL_LOCK).provider(MapGlobalLockProviderFactory.PROVIDER_ID) .config(STORAGE_CONFIG, JpaMapStorageProviderFactory.PROVIDER_ID); } @Override diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index 5a8dcc18be1..66b234b1a35 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -33,7 +33,12 @@ }, "globalLock": { - "provider": "${keycloak.globalLock.provider:dblock}" + "provider": "${keycloak.globalLock.provider:dblock}", + "map": { + "storage": { + "provider": "${keycloak.lock.map.storage.provider,keycloak.mapStorage.provider.default:concurrenthashmap}" + } + } }, "realm": {