diff --git a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-model-infinispan/main/module.xml b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-model-infinispan/main/module.xml
index a80a008a32..e7fdb8abed 100755
--- a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-model-infinispan/main/module.xml
+++ b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-model-infinispan/main/module.xml
@@ -30,6 +30,9 @@
+
+
+
diff --git a/distribution/server-overlay/src/main/cli/keycloak-install-ha.cli b/distribution/server-overlay/src/main/cli/keycloak-install-ha.cli
index 17fd5f0d25..a3b85f17c7 100644
--- a/distribution/server-overlay/src/main/cli/keycloak-install-ha.cli
+++ b/distribution/server-overlay/src/main/cli/keycloak-install-ha.cli
@@ -2,9 +2,9 @@ embed-server --server-config=standalone-ha.xml
/subsystem=datasources/data-source=KeycloakDS/:add(connection-url="jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE",jta=false,driver-name=h2,jndi-name=java:jboss/datasources/KeycloakDS,password=sa,user-name=sa,use-java-context=true)
/subsystem=infinispan/cache-container=keycloak:add(jndi-name="infinispan/Keycloak")
/subsystem=infinispan/cache-container=keycloak/transport=TRANSPORT:add(lock-timeout=60000)
-/subsystem=infinispan/cache-container=keycloak/invalidation-cache=realms:add(mode="SYNC")
-/subsystem=infinispan/cache-container=keycloak/invalidation-cache=users:add(mode="SYNC")
-/subsystem=infinispan/cache-container=keycloak/invalidation-cache=users/eviction=EVICTION:add(max-entries=10000,strategy=LRU)
+/subsystem=infinispan/cache-container=keycloak/local-cache=realms:add()
+/subsystem=infinispan/cache-container=keycloak/local-cache=users:add()
+/subsystem=infinispan/cache-container=keycloak/local-cache=users/eviction=EVICTION:add(max-entries=10000,strategy=LRU)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=sessions:add(mode="SYNC",owners="1")
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineSessions:add(mode="SYNC",owners="1")
/subsystem=infinispan/cache-container=keycloak/distributed-cache=loginFailures:add(mode="SYNC",owners="1")
diff --git a/misc/CrossDataCenter.md b/misc/CrossDataCenter.md
new file mode 100644
index 0000000000..4146eaa9e6
--- /dev/null
+++ b/misc/CrossDataCenter.md
@@ -0,0 +1,116 @@
+Test Cross-Data-Center scenario (test with external JDG server)
+===============================================================
+
+These are temporary notes. This docs should be removed once we have cross-DC support finished and properly documented.
+
+What is working right now is:
+- Propagating of invalidation messages for "realms" and "users" caches
+- All the other things provided by ClusterProvider, which is:
+-- ClusterStartupTime (used for offlineSessions and revokeRefreshToken) is shared for all clusters in all datacenters
+-- Periodic userStorage synchronization is always executed just on one node at a time. It won't be never executed concurrently on more nodes (Assuming "nodes" refer to all servers in all clusters in all datacenters)
+
+What doesn't work right now:
+- UserSessionProvider and offline sessions
+
+
+Basic setup
+===========
+
+This is setup with 2 keycloak nodes, which are NOT in cluster. They just share the same database and they will be configured with "work" infinispan cache with remoteStore, which will point
+to external JDG server.
+
+JDG Server setup
+----------------
+- Download JDG 7.0 server and unzip to some folder
+
+- Add this into JDG_HOME/standalone/configuration/standalone.xml under cache-container named "local" :
+
+```
+
+```
+
+- Start server:
+```
+cd JDG_HOME/bin
+./standalone.sh -Djboss.socket.binding.port-offset=100
+```
+
+Keycloak servers setup
+----------------------
+You need to setup 2 Keycloak nodes in this way.
+
+For now, it's recommended to test Keycloak overlay on EAP7 because of infinispan bug, which is fixed in EAP 7.0 (infinispan 8.1.2), but not
+yet on Wildfly 10 (infinispan 8.1.0). See below for details.
+
+1) Configure shared database in KEYCLOAK_HOME/standalone/configuration/standalone.xml . For example MySQL
+
+2) Add `module` attribute to the infinispan keycloak container:
+
+```
+
+```
+
+3) Configure `work` cache to use remoteStore. You should use this:
+
+```
+
+
+ true
+ org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory
+
+
+```
+
+4) Configure connection to the external JDG server. Because we used port offset 100 for JDG (see above), the HotRod endpoint is running on 11322 .
+So add the config like this to the bottom of standalone.xml under `socket-binding-group` element:
+
+```
+
+
+
+```
+
+5) Optional: Configure logging in standalone.xml to see what invalidation events were send:
+````
+
+
+
+
+
+
+````
+
+6) Setup Keycloak node2 . Just copy Keycloak to another location on your laptop and repeat steps 1-5 above for second server too.
+
+7) Run server 1 with parameters like (assuming you have virtual hosts "node1" and "node2" defined in your `/etc/hosts` ):
+```
+./standalone.sh -Djboss.node.name=node1 -b node1 -bmanagement node1
+```
+
+and server2 with:
+```
+./standalone.sh -Djboss.node.name=node2 -b node2 -bmanagement node2
+```
+
+8) Note something like this in both `KEYCLOAK_HOME/standalone/log/server.log` on both nodes. Note that cluster Startup Time will be same time on both nodes:
+```
+2016-11-16 22:12:52,080 DEBUG [org.keycloak.cluster.infinispan.InfinispanClusterProviderFactory] (ServerService Thread Pool -- 62) My address: node1-1953169551
+2016-11-16 22:12:52,081 DEBUG [org.keycloak.cluster.infinispan.CrossDCAwareCacheFactory] (ServerService Thread Pool -- 62) RemoteStore is available. Cross-DC scenario will be used
+2016-11-16 22:12:52,119 DEBUG [org.keycloak.cluster.infinispan.InfinispanClusterProviderFactory] (ServerService Thread Pool -- 62) Loaded cluster startup time: Wed Nov 16 22:09:48 CET 2016
+2016-11-16 22:12:52,128 DEBUG [org.keycloak.cluster.infinispan.InfinispanNotificationsManager] (ServerService Thread Pool -- 62) Added listener for HotRod remoteStore cache: work
+```
+
+9) Login to node1. Then change any realm on node2. You will see in the node2 server.log that RealmUpdatedEvent was sent and on node1 that this event was received.
+
+This is done even if node1 and node2 are NOT in cluster as it's the external JDG used for communication between 2 keycloak servers and sending/receiving cache invalidation events. But note that userSession
+doesn't yet work (eg. if you login to node1, you won't see the userSession on node2).
+
+
+WARNING: Previous steps works on Keycloak server overlay deployed on EAP 7.0 . With deploy on Wildfly 10.0.0.Final, you will see exception
+at startup caused by the bug https://issues.jboss.org/browse/ISPN-6203 .
+
+There is a workaround to add this line into KEYCLOAK_HOME/modules/system/layers/base/org/wildfly/clustering/service/main/module.xml :
+
+```
+
+```
diff --git a/model/infinispan/pom.xml b/model/infinispan/pom.xml
index f10fa60751..fba921c627 100755
--- a/model/infinispan/pom.xml
+++ b/model/infinispan/pom.xml
@@ -48,6 +48,10 @@
org.infinispan
infinispan-core
+
+ org.infinispan
+ infinispan-cachestore-remote
+
junit
junit
diff --git a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/CrossDCAwareCacheFactory.java b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/CrossDCAwareCacheFactory.java
new file mode 100644
index 0000000000..17795ca213
--- /dev/null
+++ b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/CrossDCAwareCacheFactory.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.cluster.infinispan;
+
+import java.io.Serializable;
+import java.util.Set;
+
+import org.infinispan.Cache;
+import org.infinispan.client.hotrod.Flag;
+import org.infinispan.client.hotrod.RemoteCache;
+import org.infinispan.commons.api.BasicCache;
+import org.infinispan.persistence.remote.RemoteStore;
+import org.jboss.logging.Logger;
+
+/**
+ * @author Marek Posolda
+ */
+abstract class CrossDCAwareCacheFactory {
+
+ protected static final Logger logger = Logger.getLogger(CrossDCAwareCacheFactory.class);
+
+
+ abstract BasicCache getCache();
+
+
+ static CrossDCAwareCacheFactory getFactory(Cache workCache, Set remoteStores) {
+ if (remoteStores.isEmpty()) {
+ logger.debugf("No configured remoteStore available. Cross-DC scenario is not used");
+ return new InfinispanCacheWrapperFactory(workCache);
+ } else {
+ logger.debugf("RemoteStore is available. Cross-DC scenario will be used");
+
+ if (remoteStores.size() > 1) {
+ logger.warnf("More remoteStores configured for work cache. Will use just the first one");
+ }
+
+ // For cross-DC scenario, we need to return underlying remoteCache for atomic operations to work properly
+ RemoteStore remoteStore = remoteStores.iterator().next();
+ RemoteCache remoteCache = remoteStore.getRemoteCache();
+ return new RemoteCacheWrapperFactory(remoteCache);
+ }
+ }
+
+
+ // We don't have external JDG configured. No cross-DC.
+ private static class InfinispanCacheWrapperFactory extends CrossDCAwareCacheFactory {
+
+ private final Cache workCache;
+
+ InfinispanCacheWrapperFactory(Cache workCache) {
+ this.workCache = workCache;
+ }
+
+ @Override
+ BasicCache getCache() {
+ return workCache;
+ }
+
+ }
+
+
+ // We have external JDG configured. Cross-DC should be enabled
+ private static class RemoteCacheWrapperFactory extends CrossDCAwareCacheFactory {
+
+ private final RemoteCache remoteCache;
+
+ RemoteCacheWrapperFactory(RemoteCache remoteCache) {
+ this.remoteCache = remoteCache;
+ }
+
+ @Override
+ BasicCache getCache() {
+ // Flags are per-invocation!
+ return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
+ }
+
+ }
+}
diff --git a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProvider.java b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProvider.java
index 8b77c25b6d..5a4bdb744b 100644
--- a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProvider.java
+++ b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProvider.java
@@ -17,20 +17,15 @@
package org.keycloak.cluster.infinispan;
-import org.infinispan.Cache;
-import org.infinispan.context.Flag;
-import org.infinispan.lifecycle.ComponentStatus;
-import org.infinispan.remoting.transport.Transport;
import org.jboss.logging.Logger;
import org.keycloak.cluster.ClusterEvent;
import org.keycloak.cluster.ClusterListener;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.cluster.ExecutionResult;
import org.keycloak.common.util.Time;
-import org.keycloak.models.KeycloakSession;
-import java.io.Serializable;
import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
/**
*
@@ -43,34 +38,22 @@ public class InfinispanClusterProvider implements ClusterProvider {
public static final String CLUSTER_STARTUP_TIME_KEY = "cluster-start-time";
private static final String TASK_KEY_PREFIX = "task::";
- private final InfinispanClusterProviderFactory factory;
- private final KeycloakSession session;
- private final Cache cache;
+ private final int clusterStartupTime;
+ private final String myAddress;
+ private final CrossDCAwareCacheFactory crossDCAwareCacheFactory;
+ private final InfinispanNotificationsManager notificationsManager; // Just to extract notifications related stuff to separate class
- public InfinispanClusterProvider(InfinispanClusterProviderFactory factory, KeycloakSession session, Cache cache) {
- this.factory = factory;
- this.session = session;
- this.cache = cache;
+ public InfinispanClusterProvider(int clusterStartupTime, String myAddress, CrossDCAwareCacheFactory crossDCAwareCacheFactory, InfinispanNotificationsManager notificationsManager) {
+ this.myAddress = myAddress;
+ this.clusterStartupTime = clusterStartupTime;
+ this.crossDCAwareCacheFactory = crossDCAwareCacheFactory;
+ this.notificationsManager = notificationsManager;
}
@Override
public int getClusterStartupTime() {
- Integer existingClusterStartTime = (Integer) cache.get(InfinispanClusterProvider.CLUSTER_STARTUP_TIME_KEY);
- if (existingClusterStartTime != null) {
- return existingClusterStartTime;
- } else {
- // clusterStartTime not yet initialized. Let's try to put our startupTime
- int serverStartTime = (int) (session.getKeycloakSessionFactory().getServerStartupTimestamp() / 1000);
-
- existingClusterStartTime = (Integer) cache.putIfAbsent(InfinispanClusterProvider.CLUSTER_STARTUP_TIME_KEY, serverStartTime);
- if (existingClusterStartTime == null) {
- logger.debugf("Initialized cluster startup time to %s", Time.toDate(serverStartTime).toString());
- return serverStartTime;
- } else {
- return existingClusterStartTime;
- }
- }
+ return clusterStartupTime;
}
@@ -104,56 +87,33 @@ public class InfinispanClusterProvider implements ClusterProvider {
@Override
public void registerListener(String taskKey, ClusterListener task) {
- factory.registerListener(taskKey, task);
+ this.notificationsManager.registerListener(taskKey, task);
}
@Override
- public void notify(String taskKey, ClusterEvent event) {
- // Put the value to the cache to notify listeners on all the nodes
- cache.put(taskKey, event);
+ public void notify(String taskKey, ClusterEvent event, boolean ignoreSender) {
+ this.notificationsManager.notify(taskKey, event, ignoreSender);
}
- private String getCurrentNode(Cache cache) {
- Transport transport = cache.getCacheManager().getTransport();
- return transport==null ? "local" : transport.getAddress().toString();
- }
-
-
- private LockEntry createLockEntry(Cache cache) {
+ private LockEntry createLockEntry() {
LockEntry lock = new LockEntry();
- lock.setNode(getCurrentNode(cache));
+ lock.setNode(myAddress);
lock.setTimestamp(Time.currentTime());
return lock;
}
private boolean tryLock(String cacheKey, int taskTimeoutInSeconds) {
- LockEntry myLock = createLockEntry(cache);
+ LockEntry myLock = createLockEntry();
- LockEntry existingLock = (LockEntry) cache.putIfAbsent(cacheKey, myLock);
+ LockEntry existingLock = (LockEntry) crossDCAwareCacheFactory.getCache().putIfAbsent(cacheKey, myLock, taskTimeoutInSeconds, TimeUnit.SECONDS);
if (existingLock != null) {
- // Task likely already in progress. Check if timestamp is not outdated
- int thatTime = existingLock.getTimestamp();
- int currentTime = Time.currentTime();
- if (thatTime + taskTimeoutInSeconds < currentTime) {
- if (logger.isTraceEnabled()) {
- logger.tracef("Task %s outdated when in progress by node %s. Will try to replace task with our node %s", cacheKey, existingLock.getNode(), myLock.getNode());
- }
- boolean replaced = cache.replace(cacheKey, existingLock, myLock);
- if (!replaced) {
- if (logger.isTraceEnabled()) {
- logger.tracef("Failed to replace the task %s. Other thread replaced in the meantime. Ignoring task.", cacheKey);
- }
- }
- return replaced;
- } else {
- if (logger.isTraceEnabled()) {
- logger.tracef("Task %s in progress already by node %s. Ignoring task.", cacheKey, existingLock.getNode());
- }
- return false;
+ if (logger.isTraceEnabled()) {
+ logger.tracef("Task %s in progress already by node %s. Ignoring task.", cacheKey, existingLock.getNode());
}
+ return false;
} else {
if (logger.isTraceEnabled()) {
logger.tracef("Successfully acquired lock for task %s. Our node is %s", cacheKey, myLock.getNode());
@@ -168,20 +128,12 @@ public class InfinispanClusterProvider implements ClusterProvider {
int retry = 3;
while (true) {
try {
- cache.getAdvancedCache()
- .withFlags(Flag.IGNORE_RETURN_VALUES, Flag.FORCE_SYNCHRONOUS)
- .remove(cacheKey);
+ crossDCAwareCacheFactory.getCache().remove(cacheKey);
if (logger.isTraceEnabled()) {
logger.tracef("Task %s removed from the cache", cacheKey);
}
return;
} catch (RuntimeException e) {
- ComponentStatus status = cache.getStatus();
- if (status.isStopping() || status.isTerminated()) {
- logger.warnf("Failed to remove task %s from the cache. Cache is already terminating", cacheKey);
- logger.debug(e.getMessage(), e);
- return;
- }
retry--;
if (retry == 0) {
throw e;
diff --git a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java
index 75aef45327..a96621d7b2 100644
--- a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java
+++ b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanClusterProviderFactory.java
@@ -20,27 +20,24 @@ package org.keycloak.cluster.infinispan;
import org.infinispan.Cache;
import org.infinispan.manager.EmbeddedCacheManager;
import org.infinispan.notifications.Listener;
-import org.infinispan.notifications.cachelistener.annotation.CacheEntryCreated;
-import org.infinispan.notifications.cachelistener.annotation.CacheEntryModified;
-import org.infinispan.notifications.cachelistener.event.CacheEntryCreatedEvent;
-import org.infinispan.notifications.cachelistener.event.CacheEntryModifiedEvent;
import org.infinispan.notifications.cachemanagerlistener.annotation.ViewChanged;
import org.infinispan.notifications.cachemanagerlistener.event.ViewChangedEvent;
+import org.infinispan.persistence.manager.PersistenceManager;
+import org.infinispan.persistence.remote.RemoteStore;
import org.infinispan.remoting.transport.Address;
import org.infinispan.remoting.transport.Transport;
import org.jboss.logging.Logger;
import org.keycloak.Config;
-import org.keycloak.cluster.ClusterEvent;
-import org.keycloak.cluster.ClusterListener;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.cluster.ClusterProviderFactory;
+import org.keycloak.common.util.HostUtils;
+import org.keycloak.common.util.Time;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import java.io.Serializable;
import java.util.Collection;
-import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
@@ -49,6 +46,8 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
+ * This impl is aware of Cross-Data-Center scenario too
+ *
* @author Marek Posolda
*/
public class InfinispanClusterProviderFactory implements ClusterProviderFactory {
@@ -57,28 +56,82 @@ public class InfinispanClusterProviderFactory implements ClusterProviderFactory
protected static final Logger logger = Logger.getLogger(InfinispanClusterProviderFactory.class);
+ // Infinispan cache
private volatile Cache workCache;
- private Map listeners = new HashMap<>();
+ // Ensure that atomic operations (like putIfAbsent) must work correctly in any of: non-clustered, clustered or cross-Data-Center (cross-DC) setups
+ private CrossDCAwareCacheFactory crossDCAwareCacheFactory;
+
+ private String myAddress;
+
+ private int clusterStartupTime;
+
+ // Just to extract notifications related stuff to separate class
+ private InfinispanNotificationsManager notificationsManager;
@Override
public ClusterProvider create(KeycloakSession session) {
lazyInit(session);
- return new InfinispanClusterProvider(this, session, workCache);
+ return new InfinispanClusterProvider(clusterStartupTime, myAddress, crossDCAwareCacheFactory, notificationsManager);
}
private void lazyInit(KeycloakSession session) {
if (workCache == null) {
synchronized (this) {
if (workCache == null) {
- workCache = session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.WORK_CACHE_NAME);
+ InfinispanConnectionProvider ispnConnections = session.getProvider(InfinispanConnectionProvider.class);
+ workCache = ispnConnections.getCache(InfinispanConnectionProvider.WORK_CACHE_NAME);
+
workCache.getCacheManager().addListener(new ViewChangeListener());
- workCache.addListener(new CacheEntryListener());
+ initMyAddress();
+
+ Set remoteStores = getRemoteStores();
+ crossDCAwareCacheFactory = CrossDCAwareCacheFactory.getFactory(workCache, remoteStores);
+
+ clusterStartupTime = initClusterStartupTime(session);
+
+ notificationsManager = InfinispanNotificationsManager.create(workCache, myAddress, remoteStores);
}
}
}
}
+
+ // See if we have RemoteStore (external JDG) configured for cross-Data-Center scenario
+ private Set getRemoteStores() {
+ return workCache.getAdvancedCache().getComponentRegistry().getComponent(PersistenceManager.class).getStores(RemoteStore.class);
+ }
+
+
+ protected void initMyAddress() {
+ Transport transport = workCache.getCacheManager().getTransport();
+ this.myAddress = transport == null ? HostUtils.getHostName() + "-" + workCache.hashCode() : transport.getAddress().toString();
+ logger.debugf("My address: %s", this.myAddress);
+ }
+
+
+ protected int initClusterStartupTime(KeycloakSession session) {
+ Integer existingClusterStartTime = (Integer) crossDCAwareCacheFactory.getCache().get(InfinispanClusterProvider.CLUSTER_STARTUP_TIME_KEY);
+ if (existingClusterStartTime != null) {
+ logger.debugf("Loaded cluster startup time: %s", Time.toDate(existingClusterStartTime).toString());
+ return existingClusterStartTime;
+ } else {
+ // clusterStartTime not yet initialized. Let's try to put our startupTime
+ int serverStartTime = (int) (session.getKeycloakSessionFactory().getServerStartupTimestamp() / 1000);
+
+ existingClusterStartTime = (Integer) crossDCAwareCacheFactory.getCache().putIfAbsent(InfinispanClusterProvider.CLUSTER_STARTUP_TIME_KEY, serverStartTime);
+ if (existingClusterStartTime == null) {
+ logger.debugf("Initialized cluster startup time to %s", Time.toDate(serverStartTime).toString());
+ return serverStartTime;
+ } else {
+ logger.debugf("Loaded cluster startup time: %s", Time.toDate(existingClusterStartTime).toString());
+ return existingClusterStartTime;
+ }
+ }
+ }
+
+
+
@Override
public void init(Config.Scope config) {
}
@@ -167,34 +220,4 @@ public class InfinispanClusterProviderFactory implements ClusterProviderFactory
}
- void registerListener(String taskKey, ClusterListener task) {
- listeners.put(taskKey, task);
- }
-
- @Listener
- public class CacheEntryListener {
-
- @CacheEntryCreated
- public void cacheEntryCreated(CacheEntryCreatedEvent event) {
- if (!event.isPre()) {
- trigger(event.getKey(), event.getValue());
- }
- }
-
- @CacheEntryModified
- public void cacheEntryModified(CacheEntryModifiedEvent event) {
- if (!event.isPre()) {
- trigger(event.getKey(), event.getValue());
- }
- }
-
- private void trigger(String key, Object value) {
- ClusterListener task = listeners.get(key);
- if (task != null) {
- ClusterEvent event = (ClusterEvent) value;
- task.run(event);
- }
- }
- }
-
}
diff --git a/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanNotificationsManager.java b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanNotificationsManager.java
new file mode 100644
index 0000000000..57cc003a53
--- /dev/null
+++ b/model/infinispan/src/main/java/org/keycloak/cluster/infinispan/InfinispanNotificationsManager.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.cluster.infinispan;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.infinispan.Cache;
+import org.infinispan.client.hotrod.RemoteCache;
+import org.infinispan.client.hotrod.annotation.ClientCacheEntryCreated;
+import org.infinispan.client.hotrod.annotation.ClientCacheEntryModified;
+import org.infinispan.client.hotrod.annotation.ClientListener;
+import org.infinispan.client.hotrod.event.ClientCacheEntryCreatedEvent;
+import org.infinispan.client.hotrod.event.ClientCacheEntryModifiedEvent;
+import org.infinispan.client.hotrod.event.ClientEvent;
+import org.infinispan.context.Flag;
+import org.infinispan.marshall.core.MarshalledEntry;
+import org.infinispan.notifications.Listener;
+import org.infinispan.notifications.cachelistener.annotation.CacheEntryCreated;
+import org.infinispan.notifications.cachelistener.annotation.CacheEntryModified;
+import org.infinispan.notifications.cachelistener.event.CacheEntryCreatedEvent;
+import org.infinispan.notifications.cachelistener.event.CacheEntryModifiedEvent;
+import org.infinispan.persistence.manager.PersistenceManager;
+import org.infinispan.persistence.remote.RemoteStore;
+import org.infinispan.remoting.transport.Transport;
+import org.jboss.logging.Logger;
+import org.keycloak.cluster.ClusterEvent;
+import org.keycloak.cluster.ClusterListener;
+import org.keycloak.cluster.ClusterProvider;
+import org.keycloak.common.util.HostUtils;
+import org.keycloak.common.util.MultivaluedHashMap;
+
+/**
+ * Impl for sending infinispan messages across cluster and listening to them
+ *
+ * @author Marek Posolda
+ */
+public class InfinispanNotificationsManager {
+
+ protected static final Logger logger = Logger.getLogger(InfinispanNotificationsManager.class);
+
+ private final MultivaluedHashMap listeners = new MultivaluedHashMap<>();
+
+ private final Cache workCache;
+
+ private final String myAddress;
+
+
+ protected InfinispanNotificationsManager(Cache workCache, String myAddress) {
+ this.workCache = workCache;
+ this.myAddress = myAddress;
+ }
+
+
+ // Create and init manager including all listeners etc
+ public static InfinispanNotificationsManager create(Cache workCache, String myAddress, Set remoteStores) {
+ InfinispanNotificationsManager manager = new InfinispanNotificationsManager(workCache, myAddress);
+
+ // We need CacheEntryListener just if we don't have remoteStore. With remoteStore will be all cluster nodes notified anyway from HotRod listener
+ if (remoteStores.isEmpty()) {
+ workCache.addListener(manager.new CacheEntryListener());
+
+ logger.debugf("Added listener for infinispan cache: %s", workCache.getName());
+ } else {
+ for (RemoteStore remoteStore : remoteStores) {
+ RemoteCache
+
+ org.infinispan
+ infinispan-cachestore-remote
+ ${infinispan.version}
+
org.liquibase
liquibase-core
diff --git a/server-spi-private/src/main/java/org/keycloak/cluster/ClusterListener.java b/server-spi-private/src/main/java/org/keycloak/cluster/ClusterListener.java
index 41c65c03d5..2c07377e36 100644
--- a/server-spi-private/src/main/java/org/keycloak/cluster/ClusterListener.java
+++ b/server-spi-private/src/main/java/org/keycloak/cluster/ClusterListener.java
@@ -29,6 +29,6 @@ public interface ClusterListener {
*
* @param event value of notification (Object added into the cache)
*/
- void run(ClusterEvent event);
+ void eventReceived(ClusterEvent event);
}
diff --git a/server-spi-private/src/main/java/org/keycloak/cluster/ClusterProvider.java b/server-spi-private/src/main/java/org/keycloak/cluster/ClusterProvider.java
index 6c22056c99..abed174520 100644
--- a/server-spi-private/src/main/java/org/keycloak/cluster/ClusterProvider.java
+++ b/server-spi-private/src/main/java/org/keycloak/cluster/ClusterProvider.java
@@ -48,7 +48,8 @@ public interface ClusterProvider extends Provider {
/**
- * Register task (listener) under given key. When this key will be put to the cache on any cluster node, the task will be executed
+ * Register task (listener) under given key. When this key will be put to the cache on any cluster node, the task will be executed.
+ * When using {@link #ALL} as the taskKey, then listener will be always triggered for any value put into the cache.
*
* @param taskKey
* @param task
@@ -57,10 +58,18 @@ public interface ClusterProvider extends Provider {
/**
- * Notify registered listeners on all cluster nodes
+ * Notify registered listeners on all cluster nodes. It will notify listeners registered under given taskKey AND also listeners registered with {@link #ALL} key (those are always executed)
*
* @param taskKey
* @param event
+ * @param ignoreSender if true, then sender node itself won't receive the notification
*/
- void notify(String taskKey, ClusterEvent event);
+ void notify(String taskKey, ClusterEvent event, boolean ignoreSender);
+
+
+ /**
+ * Special value to be used with {@link #registerListener} to specify that particular listener will be always triggered for all notifications
+ * with any key.
+ */
+ String ALL = "ALL";
}
diff --git a/server-spi-private/src/main/java/org/keycloak/models/cache/CacheRealmProvider.java b/server-spi-private/src/main/java/org/keycloak/models/cache/CacheRealmProvider.java
index 7bc1299764..61ae1beabc 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/cache/CacheRealmProvider.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/cache/CacheRealmProvider.java
@@ -27,12 +27,12 @@ public interface CacheRealmProvider extends RealmProvider {
void clear();
RealmProvider getDelegate();
- void registerRealmInvalidation(String id);
+ void registerRealmInvalidation(String id, String name);
- void registerClientInvalidation(String id);
+ void registerClientInvalidation(String id, String clientId, String realmId);
void registerClientTemplateInvalidation(String id);
- void registerRoleInvalidation(String id);
+ void registerRoleInvalidation(String id, String roleName, String roleContainerId);
void registerGroupInvalidation(String id);
}
diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
index e9df047418..09720a7b5f 100755
--- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java
+++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
@@ -351,8 +351,6 @@ public interface RealmModel extends RoleContainerModel {
void setNotBefore(int notBefore);
- boolean removeRoleById(String id);
-
boolean isEventsEnabled();
void setEventsEnabled(boolean enabled);
@@ -397,13 +395,6 @@ public interface RealmModel extends RoleContainerModel {
GroupModel createGroup(String name);
GroupModel createGroup(String id, String name);
- /**
- * Move Group to top realm level. Basically just sets group parent to null. You need to call this though
- * to make sure caches are set properly
- *
- * @param subGroup
- */
- void addTopLevelGroup(GroupModel subGroup);
GroupModel getGroupById(String id);
List getGroups();
List getTopLevelGroups();
diff --git a/services/src/main/java/org/keycloak/services/managers/UserStorageSyncManager.java b/services/src/main/java/org/keycloak/services/managers/UserStorageSyncManager.java
index 05b07f2ed4..b1114fabe2 100755
--- a/services/src/main/java/org/keycloak/services/managers/UserStorageSyncManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/UserStorageSyncManager.java
@@ -172,7 +172,7 @@ public class UserStorageSyncManager {
}
UserStorageProviderClusterEvent event = UserStorageProviderClusterEvent.createEvent(removed, realm.getId(), provider);
- session.getProvider(ClusterProvider.class).notify(USER_STORAGE_TASK_KEY, event);
+ session.getProvider(ClusterProvider.class).notify(USER_STORAGE_TASK_KEY, event, false);
}
@@ -282,7 +282,7 @@ public class UserStorageSyncManager {
}
@Override
- public void run(ClusterEvent event) {
+ public void eventReceived(ClusterEvent event) {
final UserStorageProviderClusterEvent fedEvent = (UserStorageProviderClusterEvent) event;
KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() {
diff --git a/services/src/main/java/org/keycloak/services/managers/UsersSyncManager.java b/services/src/main/java/org/keycloak/services/managers/UsersSyncManager.java
index d8c5dee5ad..21b0cad243 100755
--- a/services/src/main/java/org/keycloak/services/managers/UsersSyncManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/UsersSyncManager.java
@@ -155,7 +155,7 @@ public class UsersSyncManager {
// Ensure all cluster nodes are notified
public void notifyToRefreshPeriodicSync(KeycloakSession session, RealmModel realm, UserFederationProviderModel federationProvider, boolean removed) {
FederationProviderClusterEvent event = FederationProviderClusterEvent.createEvent(removed, realm.getId(), federationProvider);
- session.getProvider(ClusterProvider.class).notify(FEDERATION_TASK_KEY, event);
+ session.getProvider(ClusterProvider.class).notify(FEDERATION_TASK_KEY, event, false);
}
@@ -265,7 +265,7 @@ public class UsersSyncManager {
}
@Override
- public void run(ClusterEvent event) {
+ public void eventReceived(ClusterEvent event) {
final FederationProviderClusterEvent fedEvent = (FederationProviderClusterEvent) event;
KeycloakModelUtils.runJobInTransaction(sessionFactory, new KeycloakSessionTask() {
diff --git a/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java b/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java
index 363d6f4774..2ea9992fd2 100644
--- a/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java
+++ b/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java
@@ -51,6 +51,7 @@ import org.keycloak.services.resources.admin.AdminRoot;
import org.keycloak.services.scheduled.ClearExpiredEvents;
import org.keycloak.services.scheduled.ClearExpiredUserSessions;
import org.keycloak.services.scheduled.ClusterAwareScheduledTaskRunner;
+import org.keycloak.services.scheduled.ScheduledTaskRunner;
import org.keycloak.services.util.JsonConfigProvider;
import org.keycloak.services.util.ObjectMapperResolver;
import org.keycloak.timer.TimerProvider;
@@ -321,7 +322,7 @@ public class KeycloakApplication extends Application {
try {
TimerProvider timer = session.getProvider(TimerProvider.class);
timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredEvents(), interval), interval, "ClearExpiredEvents");
- timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredUserSessions(), interval), interval, "ClearExpiredUserSessions");
+ timer.schedule(new ScheduledTaskRunner(sessionFactory, new ClearExpiredUserSessions()), interval, "ClearExpiredUserSessions");
new UsersSyncManager().bootstrapPeriodic(sessionFactory, timer);
new UserStorageSyncManager().bootstrapPeriodic(sessionFactory, timer);
} finally {
diff --git a/testsuite/integration/pom.xml b/testsuite/integration/pom.xml
index 873d99ddfe..8a2dc4b29f 100755
--- a/testsuite/integration/pom.xml
+++ b/testsuite/integration/pom.xml
@@ -232,6 +232,10 @@
org.infinispan
infinispan-core
+
+ org.infinispan
+ infinispan-cachestore-remote
+
org.seleniumhq.selenium
selenium-java
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPGroupMapperSyncTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPGroupMapperSyncTest.java
index 66321245d3..1fec6d9f01 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPGroupMapperSyncTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/ldap/LDAPGroupMapperSyncTest.java
@@ -238,7 +238,7 @@ public class LDAPGroupMapperSyncTest {
GroupModel model1 = realm.createGroup("model1");
realm.moveGroup(model1, null);
GroupModel model2 = realm.createGroup("model2");
- kcGroup1.addChild(model2);
+ realm.moveGroup(model2, kcGroup1);
// Sync groups again from LDAP. Nothing deleted
syncResult = new GroupLDAPStorageMapperFactory().create(session, mapperModel).syncDataFromFederationProviderToKeycloak(mapperModel, ldapProvider, session, realm);
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ClusterInvalidationTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ClusterInvalidationTest.java
new file mode 100644
index 0000000000..f71d0da25a
--- /dev/null
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ClusterInvalidationTest.java
@@ -0,0 +1,420 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.testsuite.model;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.infinispan.Cache;
+import org.infinispan.notifications.Listener;
+import org.infinispan.notifications.cachelistener.annotation.CacheEntryRemoved;
+import org.infinispan.notifications.cachelistener.event.CacheEntryRemovedEvent;
+import org.jboss.logging.Logger;
+import org.junit.Assert;
+import org.junit.ClassRule;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.ClientTemplateModel;
+import org.keycloak.models.GroupModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.RoleModel;
+import org.keycloak.models.UserConsentModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.services.managers.RealmManager;
+import org.keycloak.testsuite.KeycloakServer;
+import org.keycloak.testsuite.rule.KeycloakRule;
+import org.keycloak.testsuite.util.cli.TestCacheUtils;
+
+/**
+ * Requires execution with cluster (or external JDG) enabled and real database, which will be shared for both cluster nodes. Everything set by system properties:
+ *
+ * 1) Use those system properties to run against shared MySQL:
+ *
+ * -Dkeycloak.connectionsJpa.url=jdbc:mysql://localhost/keycloak -Dkeycloak.connectionsJpa.driver=com.mysql.jdbc.Driver -Dkeycloak.connectionsJpa.user=keycloak
+ * -Dkeycloak.connectionsJpa.password=keycloak
+ *
+ *
+ * 2) Then either choose from:
+ *
+ * 2.a) Run test with 2 keycloak nodes in cluster. Add this system property for that: -Dkeycloak.connectionsInfinispan.clustered=true
+ *
+ * 2.b) Run test with 2 keycloak nodes without cluster, but instead with external JDG. Both keycloak servers will send invalidation events to the JDG server and receive the events from this JDG server.
+ * They don't communicate with each other. So JDG is man-in-the-middle.
+ *
+ * This assumes that you have JDG 7.0 server running on localhost with HotRod endpoint on port 11222 (which is default port anyway).
+ *
+ * You also need to have this cache configured in JDG_HOME/standalone/configuration/standalone.xml to infinispan subsystem :
+ *
+ *
+ *
+ * Finally, add this system property when running the test: -Dkeycloak.connectionsInfinispan.remoteStoreEnabled=true
+ *
+ * @author Marek Posolda
+ */
+@Ignore
+public class ClusterInvalidationTest {
+
+ protected static final Logger logger = Logger.getLogger(ClusterInvalidationTest.class);
+
+ private static final String REALM_NAME = "test";
+
+ private static final int SLEEP_TIME_MS = Integer.parseInt(System.getProperty("sleep.time", "500"));
+
+ private static TestListener listener1realms;
+ private static TestListener listener1users;
+ private static TestListener listener2realms;
+ private static TestListener listener2users;
+
+ @ClassRule
+ public static KeycloakRule server1 = new KeycloakRule(new KeycloakRule.KeycloakSetup() {
+
+ @Override
+ public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+ InfinispanConnectionProvider infinispan = manager.getSession().getProvider(InfinispanConnectionProvider.class);
+
+ Cache cache = infinispan.getCache(InfinispanConnectionProvider.REALM_CACHE_NAME);
+ listener1realms = new TestListener("server1 - realms", cache);
+ cache.addListener(listener1realms);
+
+ cache = infinispan.getCache(InfinispanConnectionProvider.USER_CACHE_NAME);
+ listener1users = new TestListener("server1 - users", cache);
+ cache.addListener(listener1users);
+ }
+
+ });
+
+ @ClassRule
+ public static KeycloakRule server2 = new KeycloakRule(new KeycloakRule.KeycloakSetup() {
+
+ @Override
+ public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+ InfinispanConnectionProvider infinispan = manager.getSession().getProvider(InfinispanConnectionProvider.class);
+
+ Cache cache = infinispan.getCache(InfinispanConnectionProvider.REALM_CACHE_NAME);
+ listener2realms = new TestListener("server2 - realms", cache);
+ cache.addListener(listener2realms);
+
+ cache = infinispan.getCache(InfinispanConnectionProvider.USER_CACHE_NAME);
+ listener2users = new TestListener("server2 - users", cache);
+ cache.addListener(listener2users);
+ }
+
+ }) {
+
+ @Override
+ protected void configureServer(KeycloakServer server) {
+ server.getConfig().setPort(8082);
+ }
+
+ @Override
+ protected void importRealm() {
+ }
+
+ @Override
+ protected void removeTestRealms() {
+ }
+
+ };
+
+ private static void clearListeners() {
+ listener1realms.getInvalidationsAndClear();
+ listener1users.getInvalidationsAndClear();
+ listener2realms.getInvalidationsAndClear();
+ listener2users.getInvalidationsAndClear();
+ }
+
+
+ @Test
+ public void testClusterInvalidation() throws Exception {
+ cacheEverything();
+
+ clearListeners();
+
+ KeycloakSession session1 = server1.startSession();
+
+
+ logger.info("UPDATE REALM");
+
+ RealmModel realm = session1.realms().getRealmByName(REALM_NAME);
+ realm.setDisplayName("foo");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 1, 3, realm.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 1, 3, realm.getId());
+
+
+ // CREATES
+
+ logger.info("CREATE ROLE");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ realm.addRole("foo-role");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 1, 1, "test.roles");
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 1, 1, "test.roles");
+
+
+ logger.info("CREATE CLIENT");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ realm.addClient("foo-client");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 1, 1, "test.realm.clients");
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 1, 1, "test.realm.clients");
+
+ logger.info("CREATE GROUP");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ GroupModel group = realm.createGroup("foo-group");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 1, 1, "test.top.groups");
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 1, 1, "test.top.groups");
+
+ logger.info("CREATE CLIENT TEMPLATE");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ realm.addClientTemplate("foo-template");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 2, 3, realm.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 0, 2); // realm not cached on server2 due to previous invalidation
+
+
+ // UPDATES
+
+ logger.info("UPDATE ROLE");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ ClientModel testApp = realm.getClientByClientId("test-app");
+ RoleModel role = session1.realms().getClientRole(realm, testApp, "customer-user");
+ role.setDescription("Foo");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 2, 3, role.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 2, 3, role.getId());
+
+ logger.info("UPDATE GROUP");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ group = KeycloakModelUtils.findGroupByPath(realm, "/topGroup");
+ group.grantRole(role);
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 1, 1, group.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 1, 1, group.getId());
+
+ logger.info("UPDATE CLIENT");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ testApp = realm.getClientByClientId("test-app");
+ testApp.setDescription("foo");;
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 2, 3, testApp.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 2, 3, testApp.getId());
+
+ // Cache client template on server2
+ KeycloakSession session2 = server2.startSession();
+ realm = session2.realms().getRealmByName(REALM_NAME);
+ realm.getClientTemplates().get(0);
+
+
+ logger.info("UPDATE CLIENT TEMPLATE");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ ClientTemplateModel clientTemplate = realm.getClientTemplates().get(0);
+ clientTemplate.setDescription("bar");
+
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 1, 1, clientTemplate.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 1, 1, clientTemplate.getId());
+
+ // Nothing yet invalidated in user cache
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 0, 0);
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 0, 0);
+
+ logger.info("UPDATE USER");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ UserModel user = session1.users().getUserByEmail("keycloak-user@localhost", realm);
+ user.setSingleAttribute("foo", "Bar");
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 1, 5, user.getId(), "test.email.keycloak-user@localhost");
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 1, 5, user.getId());
+
+ logger.info("UPDATE USER CONSENTS");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ testApp = realm.getClientByClientId("test-app");
+ user = session1.users().getUserByEmail("keycloak-user@localhost", realm);
+ session1.users().addConsent(realm, user.getId(), new UserConsentModel(testApp));
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 1, 1, user.getId() + ".consents");
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 1, 1, user.getId() + ".consents");
+
+
+ // REMOVALS
+
+ logger.info("REMOVE USER");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ user = session1.users().getUserByUsername("john-doh@localhost", realm);
+ session1.users().removeUser(realm, user);
+
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 3, 5, user.getId(), user.getId() + ".consents", "test.username.john-doh@localhost");
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 2, 5, user.getId(), user.getId() + ".consents");
+
+ cacheEverything();
+
+ logger.info("REMOVE CLIENT TEMPLATE");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ realm.removeClientTemplate(clientTemplate.getId());
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 2, 5, realm.getId(), clientTemplate.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 2, 5, realm.getId(), clientTemplate.getId());
+
+ cacheEverything();
+
+ logger.info("REMOVE ROLE");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ role = realm.getRole("user");
+ realm.removeRole(role);
+ ClientModel thirdparty = session1.realms().getClientByClientId("third-party", realm);
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 7, 10, role.getId(), realm.getId(), "test.roles", "test.user.roles", testApp.getId(), thirdparty.getId(), group.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 7, 10, role.getId(), realm.getId(), "test.roles", "test.user.roles", testApp.getId(), thirdparty.getId(), group.getId());
+
+ // all users invalidated
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 10, 100);
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 10, 100);
+
+ cacheEverything();
+
+ logger.info("REMOVE GROUP");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ group = realm.getGroupById(group.getId());
+ String subgroupId = group.getSubGroups().iterator().next().getId();
+ realm.removeGroup(group);
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 3, 5, group.getId(), subgroupId, "test.top.groups");
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 3, 5, group.getId(), subgroupId, "test.top.groups");
+
+ // all users invalidated
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 10, 100);
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 10, 100);
+
+ cacheEverything();
+
+ logger.info("REMOVE CLIENT");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ testApp = realm.getClientByClientId("test-app");
+ role = testApp.getRole("customer-user");
+ realm.removeClient(testApp.getId());
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 8, 12, testApp.getId(), testApp.getId() + ".roles", role.getId(), testApp.getId() + ".customer-user.roles", "test.realm.clients", thirdparty.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 8, 12, testApp.getId(), testApp.getId() + ".roles", role.getId(), testApp.getId() + ".customer-user.roles", "test.realm.clients", thirdparty.getId());
+
+ // all users invalidated
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 10, 100);
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 10, 100);
+
+ cacheEverything();
+
+ logger.info("REMOVE REALM");
+ realm = session1.realms().getRealmByName(REALM_NAME);
+ session1.realms().removeRealm(realm.getId());
+ session1 = commit(server1, session1, true);
+
+ assertInvalidations(listener1realms.getInvalidationsAndClear(), 50, 200, realm.getId(), thirdparty.getId());
+ assertInvalidations(listener2realms.getInvalidationsAndClear(), 50, 200, realm.getId(), thirdparty.getId());
+
+ // all users invalidated
+ assertInvalidations(listener1users.getInvalidationsAndClear(), 10, 100);
+ assertInvalidations(listener2users.getInvalidationsAndClear(), 10, 100);
+
+
+ //Thread.sleep(10000000);
+ }
+
+ private void assertInvalidations(Map invalidations, int low, int high, String... expectedNames) {
+ int size = invalidations.size();
+ Assert.assertTrue("Size was " + size + ". Entries were: " + invalidations.keySet(), size >= low);
+ Assert.assertTrue("Size was " + size + ". Entries were: " + invalidations.keySet(), size <= high);
+
+ for (String expected : expectedNames) {
+ Assert.assertTrue("Can't find " + expected + ". Entries were: " + invalidations.keySet(), invalidations.keySet().contains(expected));
+ }
+ }
+
+ private KeycloakSession commit(KeycloakRule rule, KeycloakSession session, boolean sleepAfterCommit) throws Exception {
+ session.getTransactionManager().commit();
+ session.close();
+
+ if (sleepAfterCommit) {
+ Thread.sleep(SLEEP_TIME_MS);
+ }
+
+ return rule.startSession();
+ }
+
+ private void cacheEverything() throws Exception {
+ KeycloakSession session1 = server1.startSession();
+ TestCacheUtils.cacheRealmWithEverything(session1, REALM_NAME);
+ session1 = commit(server1, session1, false);
+
+ KeycloakSession session2 = server2.startSession();
+ TestCacheUtils.cacheRealmWithEverything(session2, REALM_NAME);
+ session2 = commit(server1, session2, false);
+ }
+
+
+ @Listener(observation = Listener.Observation.PRE)
+ public static class TestListener {
+
+ private final String name;
+ private final Cache cache; // Just for debugging
+
+ private Map invalidations = new ConcurrentHashMap<>();
+
+ public TestListener(String name, Cache cache) {
+ this.name = name;
+ this.cache = cache;
+ }
+
+ @CacheEntryRemoved
+ public void cacheEntryRemoved(CacheEntryRemovedEvent event) {
+ logger.infof("%s: Invalidated %s: %s", name, event.getKey(), event.getValue());
+ invalidations.put(event.getKey().toString(), event.getValue());
+ }
+
+ Map getInvalidationsAndClear() {
+ Map newMap = new HashMap<>(invalidations);
+ invalidations.clear();
+ return newMap;
+ }
+
+ }
+
+
+}
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/CacheCommands.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/CacheCommands.java
new file mode 100644
index 0000000000..0c7eff0450
--- /dev/null
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/CacheCommands.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.testsuite.util.cli;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.infinispan.Cache;
+import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+
+/**
+ * @author Marek Posolda
+ */
+public class CacheCommands {
+
+ public static class ListCachesCommand extends AbstractCommand {
+
+ @Override
+ public String getName() {
+ return "listCaches";
+ }
+
+ @Override
+ protected void doRunCommand(KeycloakSession session) {
+ InfinispanConnectionProvider ispnProvider = session.getProvider(InfinispanConnectionProvider.class);
+ Set cacheNames = ispnProvider.getCache("realms").getCacheManager().getCacheNames();
+ log.infof("Available caches: %s", cacheNames);
+ }
+
+ }
+
+
+ public static class GetCacheCommand extends AbstractCommand {
+
+ @Override
+ public String getName() {
+ return "getCache";
+ }
+
+ @Override
+ protected void doRunCommand(KeycloakSession session) {
+ String cacheName = getArg(0);
+ InfinispanConnectionProvider ispnProvider = session.getProvider(InfinispanConnectionProvider.class);
+ Cache cache = ispnProvider.getCache(cacheName);
+ if (cache == null) {
+ log.errorf("Cache '%s' doesn't exist", cacheName);
+ throw new HandledException();
+ }
+
+ printCache(cache);
+ }
+
+ private void printCache(Cache cache) {
+ int size = cache.size();
+ log.infof("Cache %s, size: %d", cache.getName(), size);
+
+ if (size > 50) {
+ log.info("Skip printing cache recors due to big size");
+ } else {
+ for (Map.Entry entry : cache.entrySet()) {
+ log.infof("%s=%s", entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ @Override
+ public String printUsage() {
+ return super.printUsage() + " . cache-name is name of the infinispan cache provided by InfinispanConnectionProvider";
+ }
+
+ }
+
+
+ public static class CacheRealmObjectsCommand extends AbstractCommand {
+
+ @Override
+ public String getName() {
+ return "cacheRealmObjects";
+ }
+
+ @Override
+ protected void doRunCommand(KeycloakSession session) {
+ String realmName = getArg(0);
+ RealmModel realm = session.realms().getRealmByName(realmName);
+ if (realm == null) {
+ log.errorf("Realm not found: %s", realmName);
+ throw new HandledException();
+ }
+
+ TestCacheUtils.cacheRealmWithEverything(session, realmName);
+ }
+
+ @Override
+ public String printUsage() {
+ return super.printUsage() + " ";
+ }
+ }
+}
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/RoleCommands.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/RoleCommands.java
new file mode 100644
index 0000000000..2c71c72116
--- /dev/null
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/RoleCommands.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.testsuite.util.cli;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionTask;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.RoleContainerModel;
+import org.keycloak.models.RoleModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+
+/**
+ * @author Marek Posolda
+ */
+public class RoleCommands {
+
+ public static class CreateRoles extends AbstractCommand {
+
+ private String rolePrefix;
+ private String roleContainer;
+
+ @Override
+ public String getName() {
+ return "createRoles";
+ }
+
+ private class StateHolder {
+ int firstInThisBatch;
+ int countInThisBatch;
+ int remaining;
+ };
+
+ @Override
+ protected void doRunCommand(KeycloakSession session) {
+ rolePrefix = getArg(0);
+ roleContainer = getArg(1);
+ int first = getIntArg(2);
+ int count = getIntArg(3);
+ int batchCount = getIntArg(4);
+
+ final StateHolder state = new StateHolder();
+ state.firstInThisBatch = first;
+ state.remaining = count;
+ state.countInThisBatch = Math.min(batchCount, state.remaining);
+ while (state.remaining > 0) {
+ KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), new KeycloakSessionTask() {
+
+ @Override
+ public void run(KeycloakSession session) {
+ createRolesInBatch(session, roleContainer, rolePrefix, state.firstInThisBatch, state.countInThisBatch);
+ }
+ });
+
+ // update state
+ state.firstInThisBatch = state.firstInThisBatch + state.countInThisBatch;
+ state.remaining = state.remaining - state.countInThisBatch;
+ state.countInThisBatch = Math.min(batchCount, state.remaining);
+ }
+
+ log.infof("Command finished. All roles from %s to %s created", rolePrefix + first, rolePrefix + (first + count - 1));
+ }
+
+ private void createRolesInBatch(KeycloakSession session, String roleContainer, String rolePrefix, int first, int count) {
+ RoleContainerModel container = getRoleContainer(session, roleContainer);
+
+ int last = first + count;
+ for (int counter = first; counter < last; counter++) {
+ String roleName = rolePrefix + counter;
+ RoleModel role = container.addRole(roleName);
+ }
+ log.infof("Roles from %s to %s created", rolePrefix + first, rolePrefix + (last - 1));
+ }
+
+ private RoleContainerModel getRoleContainer(KeycloakSession session, String roleContainer) {
+ String[] parts = roleContainer.split("/");
+ String realmName = parts[0];
+
+ RealmModel realm = session.realms().getRealmByName(realmName);
+ if (realm == null) {
+ log.errorf("Unknown realm: %s", realmName);
+ throw new HandledException();
+ }
+
+ if (parts.length == 1) {
+ return realm;
+ } else {
+ String clientId = parts[1];
+ ClientModel client = session.realms().getClientByClientId(clientId, realm);
+ if (client == null) {
+ log.errorf("Unknown client: %s", clientId);
+ throw new HandledException();
+ }
+
+ return client;
+ }
+ }
+
+ @Override
+ public String printUsage() {
+ return super.printUsage() + " . " +
+ "\n'total-count' refers to total count of newly created roles. 'batch-size' refers to number of created roles in each transaction. 'starting-role-offset' refers to starting role offset." +
+ "\nFor example if 'starting-role-offset' is 15 and total-count is 10 and role-prefix is 'test', it will create roles test15, test16, test17, ... , test24" +
+ "\n'role-container' is either realm (then use just realmName like 'demo' or client (then use realm/clientId like 'demo/my-client' .\n" +
+ "Example usage: " + super.printUsage() + " test demo 0 500 100";
+ }
+
+ }
+}
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestCacheUtils.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestCacheUtils.java
new file mode 100644
index 0000000000..9792f9dff6
--- /dev/null
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestCacheUtils.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.testsuite.util.cli;
+
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.ClientTemplateModel;
+import org.keycloak.models.FederatedIdentityModel;
+import org.keycloak.models.GroupModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.RoleContainerModel;
+import org.keycloak.models.RoleModel;
+import org.keycloak.models.UserModel;
+
+/**
+ * @author Marek Posolda
+ */
+public class TestCacheUtils {
+
+ public static void cacheRealmWithEverything(KeycloakSession session, String realmName) {
+ RealmModel realm = session.realms().getRealmByName(realmName);
+
+ for (ClientModel client : realm.getClients()) {
+ realm.getClientById(client.getId());
+ realm.getClientByClientId(client.getClientId());
+
+ cacheRoles(session, realm, client);
+ }
+
+ cacheRoles(session, realm, realm);
+
+ for (GroupModel group : realm.getTopLevelGroups()) {
+ cacheGroupRecursive(realm, group);
+ }
+
+ for (ClientTemplateModel clientTemplate : realm.getClientTemplates()) {
+ realm.getClientTemplateById(clientTemplate.getId());
+ }
+
+ for (UserModel user : session.users().getUsers(realm)) {
+ session.users().getUserById(user.getId(), realm);
+ if (user.getEmail() != null) {
+ session.users().getUserByEmail(user.getEmail(), realm);
+ }
+ session.users().getUserByUsername(user.getUsername(), realm);
+
+ session.users().getConsents(realm, user.getId());
+
+ for (FederatedIdentityModel fedIdentity : session.users().getFederatedIdentities(user, realm)) {
+ session.users().getUserByFederatedIdentity(fedIdentity, realm);
+ }
+ }
+ }
+
+ private static void cacheRoles(KeycloakSession session, RealmModel realm, RoleContainerModel roleContainer) {
+ for (RoleModel role : roleContainer.getRoles()) {
+ realm.getRoleById(role.getId());
+ roleContainer.getRole(role.getName());
+ if (roleContainer instanceof RealmModel) {
+ session.realms().getRealmRole(realm, role.getName());
+ } else {
+ session.realms().getClientRole(realm, (ClientModel) roleContainer, role.getName());
+ }
+ }
+ }
+
+ private static void cacheGroupRecursive(RealmModel realm, GroupModel group) {
+ realm.getGroupById(group.getId());
+ for (GroupModel sub : group.getSubGroups()) {
+ cacheGroupRecursive(realm, sub);
+ }
+ }
+}
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java
index 8e9582bfd0..9b2c17aaca 100644
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/util/cli/TestsuiteCLI.java
@@ -57,7 +57,11 @@ public class TestsuiteCLI {
UserCommands.Remove.class,
UserCommands.Count.class,
UserCommands.GetUser.class,
- SyncDummyFederationProviderCommand.class
+ SyncDummyFederationProviderCommand.class,
+ RoleCommands.CreateRoles.class,
+ CacheCommands.ListCachesCommand.class,
+ CacheCommands.GetCacheCommand.class,
+ CacheCommands.CacheRealmObjectsCommand.class
};
private final KeycloakSessionFactory sessionFactory;
diff --git a/testsuite/integration/src/test/resources/META-INF/keycloak-server.json b/testsuite/integration/src/test/resources/META-INF/keycloak-server.json
index 06b4e5266f..3f4ddd1a1e 100755
--- a/testsuite/integration/src/test/resources/META-INF/keycloak-server.json
+++ b/testsuite/integration/src/test/resources/META-INF/keycloak-server.json
@@ -97,7 +97,10 @@
"default": {
"clustered": "${keycloak.connectionsInfinispan.clustered:false}",
"async": "${keycloak.connectionsInfinispan.async:false}",
- "sessionsOwners": "${keycloak.connectionsInfinispan.sessionsOwners:2}"
+ "sessionsOwners": "${keycloak.connectionsInfinispan.sessionsOwners:2}",
+ "remoteStoreEnabled": "${keycloak.connectionsInfinispan.remoteStoreEnabled:false}",
+ "remoteStoreHost": "${keycloak.connectionsInfinispan.remoteStoreHost:localhost}",
+ "remoteStorePort": "${keycloak.connectionsInfinispan.remoteStorePort:11222}"
}
},
diff --git a/testsuite/integration/src/test/resources/log4j.properties b/testsuite/integration/src/test/resources/log4j.properties
index f0ff6ac6e3..2fa1d70fbc 100755
--- a/testsuite/integration/src/test/resources/log4j.properties
+++ b/testsuite/integration/src/test/resources/log4j.properties
@@ -46,7 +46,8 @@ log4j.logger.org.keycloak.connections.jpa.updater.liquibase=${keycloak.liquibase
# log4j.logger.org.keycloak.models.sessions.infinispan.initializer=trace
# Enable to view cache activity
-# log4j.logger.org.keycloak.models.cache=trace
+#log4j.logger.org.keycloak.cluster.infinispan=trace
+#log4j.logger.org.keycloak.models.cache.infinispan=debug
# Enable to view database updates
# log4j.logger.org.keycloak.connections.mongo.updater.DefaultMongoUpdaterProvider=debug
diff --git a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml
index 840fa2ca24..3c8f429e1d 100755
--- a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml
+++ b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml
@@ -92,10 +92,10 @@
-
-
+
+
-
+