From 6c07679446e9b4225385d07c7efca8060550b9a3 Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Tue, 15 Dec 2020 15:32:51 +0100 Subject: [PATCH] KEYCLOAK-16584 Rename map to CRUD operations * rename putIfAbsent() to create(), get() to read(), put() to update(), remove() to delete() * move ConcurrentHashMapStorage to org.keycloak.models.map.storage.chm package * Add javadoc to MapStorage --- .../MapRootAuthenticationSessionAdapter.java | 2 +- .../MapRootAuthenticationSessionProvider.java | 16 +- .../models/map/client/MapClientProvider.java | 12 +- .../models/map/common/Serialization.java | 15 +- .../models/map/group/MapGroupProvider.java | 10 +- .../models/map/role/MapRoleProvider.java | 14 +- .../ConcurrentHashMapStorageProvider.java | 132 ---------- .../map/storage/MapKeycloakTransaction.java | 22 +- .../models/map/storage/MapStorage.java | 55 +++- .../map/storage/ModelCriteriaBuilder.java | 176 +++++++++++++ .../chm/ConcurrentHashMapStorageProvider.java | 247 ++++++++++++++++++ .../models/map/user/MapUserProvider.java | 18 +- ...loak.models.map.storage.MapStorageProvider | 2 +- .../storage/SearchableModelField.java | 40 +++ .../parameters/ConcurrentHashMapStorage.java | 2 +- 15 files changed, 575 insertions(+), 188 deletions(-) delete mode 100644 model/map/src/main/java/org/keycloak/models/map/storage/ConcurrentHashMapStorageProvider.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/storage/ModelCriteriaBuilder.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/storage/chm/ConcurrentHashMapStorageProvider.java create mode 100644 server-spi/src/main/java/org/keycloak/storage/SearchableModelField.java diff --git a/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionAdapter.java b/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionAdapter.java index 2ebf39b27d..9b5ab1813d 100644 --- a/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionAdapter.java +++ b/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionAdapter.java @@ -105,7 +105,7 @@ public class MapRootAuthenticationSessionAdapter extends AbstractRootAuthenticat if (entity.getAuthenticationSessions().isEmpty()) { MapRootAuthenticationSessionProvider authenticationSessionProvider = (MapRootAuthenticationSessionProvider) session.authenticationSessions(); - authenticationSessionProvider.tx.remove(entity.getId()); + authenticationSessionProvider.tx.delete(entity.getId()); } else { entity.setTimestamp(Time.currentTime()); } diff --git a/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java b/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java index 93a811f61c..cf8cd76bfa 100644 --- a/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/authSession/MapRootAuthenticationSessionProvider.java @@ -69,8 +69,8 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi } private MapRootAuthenticationSessionEntity registerEntityForChanges(MapRootAuthenticationSessionEntity origEntity) { - MapRootAuthenticationSessionEntity res = tx.get(origEntity.getId(), id -> Serialization.from(origEntity)); - tx.putIfChanged(origEntity.getId(), res, MapRootAuthenticationSessionEntity::isUpdated); + MapRootAuthenticationSessionEntity res = tx.read(origEntity.getId(), id -> Serialization.from(origEntity)); + tx.updateIfChanged(origEntity.getId(), res, MapRootAuthenticationSessionEntity::isUpdated); return res; } @@ -100,11 +100,11 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi entity.setRealmId(realm.getId()); entity.setTimestamp(Time.currentTime()); - if (tx.get(entity.getId(), sessionStore::get) != null) { + if (tx.read(entity.getId(), sessionStore::read) != null) { throw new ModelDuplicateException("Root authentication session exists: " + entity.getId()); } - tx.putIfAbsent(entity.getId(), entity); + tx.create(entity.getId(), entity); return entityToAdapterFunc(realm).apply(entity); } @@ -118,7 +118,7 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi LOG.tracef("getRootAuthenticationSession(%s, %s)%s", realm.getName(), authenticationSessionId, getShortStackTrace()); - MapRootAuthenticationSessionEntity entity = tx.get(UUID.fromString(authenticationSessionId), sessionStore::get); + MapRootAuthenticationSessionEntity entity = tx.read(UUID.fromString(authenticationSessionId), sessionStore::read); return (entity == null || !entityRealmFilter(realm.getId()).test(entity)) ? null : entityToAdapterFunc(realm).apply(entity); @@ -127,7 +127,7 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi @Override public void removeRootAuthenticationSession(RealmModel realm, RootAuthenticationSessionModel authenticationSession) { Objects.requireNonNull(authenticationSession, "The provided root authentication session can't be null!"); - tx.remove(UUID.fromString(authenticationSession.getId())); + tx.delete(UUID.fromString(authenticationSession.getId())); } @Override @@ -145,7 +145,7 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi LOG.debugf("Removed %d expired authentication sessions for realm '%s'", sessionIds.size(), realm.getName()); - sessionIds.forEach(tx::remove); + sessionIds.forEach(tx::delete); } @Override @@ -155,7 +155,7 @@ public class MapRootAuthenticationSessionProvider implements AuthenticationSessi .filter(entity -> entityRealmFilter(realm.getId()).test(entity.getValue())) .map(Map.Entry::getKey) .collect(Collectors.toList()) - .forEach(tx::remove); + .forEach(tx::delete); } @Override diff --git a/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java index c6ae69b208..2868ffd4f1 100644 --- a/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java @@ -87,8 +87,8 @@ public class MapClientProvider implements ClientProvider { } private MapClientEntity registerEntityForChanges(MapClientEntity origEntity) { - final MapClientEntity res = tx.get(origEntity.getId(), id -> Serialization.from(origEntity)); - tx.putIfChanged(origEntity.getId(), res, MapClientEntity::isUpdated); + final MapClientEntity res = tx.read(origEntity.getId(), id -> Serialization.from(origEntity)); + tx.updateIfChanged(origEntity.getId(), res, MapClientEntity::isUpdated); return res; } @@ -165,10 +165,10 @@ public class MapClientProvider implements ClientProvider { entity.setClientId(clientId); entity.setEnabled(true); entity.setStandardFlowEnabled(true); - if (tx.get(entity.getId(), clientStore::get) != null) { + if (tx.read(entity.getId(), clientStore::read) != null) { throw new ModelDuplicateException("Client exists: " + id); } - tx.putIfAbsent(entity.getId(), entity); + tx.create(entity.getId(), entity); final ClientModel resource = entityToAdapterFunc(realm).apply(entity); // TODO: Sending an event should be extracted to store layer @@ -221,7 +221,7 @@ public class MapClientProvider implements ClientProvider { }); // TODO: ^^^^^^^ Up to here - tx.remove(UUID.fromString(id)); + tx.delete(UUID.fromString(id)); return true; } @@ -241,7 +241,7 @@ public class MapClientProvider implements ClientProvider { LOG.tracef("getClientById(%s, %s)%s", realm, id, getShortStackTrace()); - MapClientEntity entity = tx.get(UUID.fromString(id), clientStore::get); + MapClientEntity entity = tx.read(UUID.fromString(id), clientStore::read); return (entity == null || ! entityRealmFilter(realm).test(entity)) ? null : entityToAdapterFunc(realm).apply(entity); diff --git a/model/map/src/main/java/org/keycloak/models/map/common/Serialization.java b/model/map/src/main/java/org/keycloak/models/map/common/Serialization.java index e0a1358d9b..6948c9f1c6 100644 --- a/model/map/src/main/java/org/keycloak/models/map/common/Serialization.java +++ b/model/map/src/main/java/org/keycloak/models/map/common/Serialization.java @@ -21,8 +21,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.SerializationFeature; import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; /** * @@ -31,6 +34,8 @@ import java.io.IOException; public class Serialization { public static final ObjectMapper MAPPER = new ObjectMapper(); + public static final ConcurrentHashMap, ObjectReader> READERS = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap, ObjectWriter> WRITERS = new ConcurrentHashMap<>(); abstract class IgnoreUpdatedMixIn { @JsonIgnore public abstract boolean isUpdated(); } @@ -49,10 +54,14 @@ public class Serialization { if (orig == null) { return null; } + @SuppressWarnings("unchecked") + final Class origClass = (Class) orig.getClass(); + + // Naive solution but will do. try { - // Naive solution but will do. - @SuppressWarnings("unchecked") - final T res = MAPPER.readValue(MAPPER.writeValueAsBytes(orig), (Class) orig.getClass()); + ObjectReader reader = READERS.computeIfAbsent(origClass, MAPPER::readerFor); + ObjectWriter writer = WRITERS.computeIfAbsent(origClass, MAPPER::writerFor); + final T res = reader.readValue(writer.writeValueAsBytes(orig)); return res; } catch (IOException ex) { throw new IllegalStateException(ex); diff --git a/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java b/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java index 759b5aaf73..5c2d163e6e 100644 --- a/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/group/MapGroupProvider.java @@ -55,7 +55,7 @@ public class MapGroupProvider implements GroupProvider { private MapGroupEntity registerEntityForChanges(MapGroupEntity origEntity) { final MapGroupEntity res = Serialization.from(origEntity); - tx.putIfChanged(origEntity.getId(), res, MapGroupEntity::isUpdated); + tx.updateIfChanged(origEntity.getId(), res, MapGroupEntity::isUpdated); return res; } @@ -88,7 +88,7 @@ public class MapGroupProvider implements GroupProvider { return null; } - MapGroupEntity entity = tx.get(uid, groupStore::get); + MapGroupEntity entity = tx.read(uid, groupStore::read); return (entity == null || ! entityRealmFilter(realm).test(entity)) ? null : entityToAdapterFunc(realm).apply(entity); @@ -194,10 +194,10 @@ public class MapGroupProvider implements GroupProvider { MapGroupEntity entity = new MapGroupEntity(entityId, realm.getId()); entity.setName(name); entity.setParentId(toParent == null ? null : toParent.getId()); - if (tx.get(entity.getId(), groupStore::get) != null) { + if (tx.read(entity.getId(), groupStore::read) != null) { throw new ModelDuplicateException("Group exists: " + entityId); } - tx.putIfAbsent(entity.getId(), entity); + tx.create(entity.getId(), entity); return entityToAdapterFunc(realm).apply(entity); } @@ -233,7 +233,7 @@ public class MapGroupProvider implements GroupProvider { // TODO: ^^^^^^^ Up to here - tx.remove(UUID.fromString(group.getId())); + tx.delete(UUID.fromString(group.getId())); return true; } diff --git a/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java b/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java index 75bd41719a..8a366aa142 100644 --- a/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/role/MapRoleProvider.java @@ -77,7 +77,7 @@ public class MapRoleProvider implements RoleProvider { private MapRoleEntity registerEntityForChanges(MapRoleEntity origEntity) { final MapRoleEntity res = Serialization.from(origEntity); - tx.putIfChanged(origEntity.getId(), res, MapRoleEntity::isUpdated); + tx.updateIfChanged(origEntity.getId(), res, MapRoleEntity::isUpdated); return res; } @@ -119,10 +119,10 @@ public class MapRoleProvider implements RoleProvider { MapRoleEntity entity = new MapRoleEntity(entityId, realm.getId()); entity.setName(name); entity.setRealmId(realm.getId()); - if (tx.get(entity.getId(), roleStore::get) != null) { + if (tx.read(entity.getId(), roleStore::read) != null) { throw new ModelDuplicateException("Role exists: " + id); } - tx.putIfAbsent(entity.getId(), entity); + tx.create(entity.getId(), entity); return entityToAdapterFunc(realm).apply(entity); } @@ -157,10 +157,10 @@ public class MapRoleProvider implements RoleProvider { entity.setName(name); entity.setClientRole(true); entity.setClientId(client.getId()); - if (tx.get(entity.getId(), roleStore::get) != null) { + if (tx.read(entity.getId(), roleStore::read) != null) { throw new ModelDuplicateException("Role exists: " + id); } - tx.putIfAbsent(entity.getId(), entity); + tx.create(entity.getId(), entity); return entityToAdapterFunc(client.getRealm()).apply(entity); } @@ -233,7 +233,7 @@ public class MapRoleProvider implements RoleProvider { }); // TODO: ^^^^^^^ Up to here - tx.remove(UUID.fromString(role.getId())); + tx.delete(UUID.fromString(role.getId())); return true; } @@ -295,7 +295,7 @@ public class MapRoleProvider implements RoleProvider { LOG.tracef("getRoleById(%s, %s)%s", realm.getName(), id, getShortStackTrace()); - MapRoleEntity entity = tx.get(UUID.fromString(id), roleStore::get); + MapRoleEntity entity = tx.read(UUID.fromString(id), roleStore::read); return (entity == null || ! entityRealmFilter(realm).test(entity)) ? null : entityToAdapterFunc(realm).apply(entity); diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/ConcurrentHashMapStorageProvider.java b/model/map/src/main/java/org/keycloak/models/map/storage/ConcurrentHashMapStorageProvider.java deleted file mode 100644 index 21cfa0ad4e..0000000000 --- a/model/map/src/main/java/org/keycloak/models/map/storage/ConcurrentHashMapStorageProvider.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2020 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.keycloak.models.map.storage; - -import org.keycloak.Config.Scope; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.map.common.AbstractEntity; -import org.keycloak.models.map.common.Serialization; -import com.fasterxml.jackson.databind.JavaType; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.EnumSet; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Level; -import org.jboss.logging.Logger; - -/** - * - * @author hmlnarik - */ -public class ConcurrentHashMapStorageProvider implements MapStorageProvider { - - private static class ConcurrentHashMapStorage extends ConcurrentHashMap implements MapStorage { - } - - private static final String PROVIDER_ID = "concurrenthashmap"; - - private static final Logger LOG = Logger.getLogger(ConcurrentHashMapStorageProvider.class); - - private final ConcurrentHashMap> storages = new ConcurrentHashMap<>(); - - private File storageDirectory; - - @Override - public MapStorageProvider create(KeycloakSession session) { - return this; - } - - @Override - public void init(Scope config) { - File f = new File(config.get("dir")); - try { - this.storageDirectory = f.exists() - ? f - : Files.createTempDirectory("storage-map-chm-").toFile(); - } catch (IOException ex) { - this.storageDirectory = null; - } - } - - @Override - public void postInit(KeycloakSessionFactory factory) { - } - - @Override - public void close() { - storages.forEach(this::storeMap); - } - - private void storeMap(String fileName, ConcurrentHashMap store) { - if (fileName != null) { - File f = getFile(fileName); - try { - if (storageDirectory != null && storageDirectory.exists()) { - LOG.debugf("Storing contents to %s", f.getCanonicalPath()); - Serialization.MAPPER.writeValue(f, store.values()); - } else { - LOG.debugf("Not storing contents of %s because directory %s does not exist", fileName, this.storageDirectory); - } - } catch (IOException ex) { - throw new RuntimeException(ex); - } - } - } - - private > ConcurrentHashMapStorage loadMap(String fileName, Class valueType, EnumSet flags) { - ConcurrentHashMapStorage store = new ConcurrentHashMapStorage<>(); - - if (! flags.contains(Flag.INITIALIZE_EMPTY)) { - final File f = getFile(fileName); - if (f != null && f.exists()) { - try { - LOG.debugf("Restoring contents from %s", f.getCanonicalPath()); - JavaType type = Serialization.MAPPER.getTypeFactory().constructCollectionType(List.class, valueType); - - List values = Serialization.MAPPER.readValue(f, type); - values.forEach((V mce) -> store.put(mce.getId(), mce)); - } catch (IOException ex) { - throw new RuntimeException(ex); - } - } - } - - return store; - } - - @Override - public String getId() { - return PROVIDER_ID; - } - - @Override - @SuppressWarnings("unchecked") - public > MapStorage getStorage(String name, Class keyType, Class valueType, Flag... flags) { - EnumSet f = flags == null || flags.length == 0 ? EnumSet.noneOf(Flag.class) : EnumSet.of(flags[0], flags); - return (MapStorage) storages.computeIfAbsent(name, n -> loadMap(name, valueType, f)); - } - - private File getFile(String fileName) { - return storageDirectory == null - ? null - : new File(storageDirectory, "map-" + fileName + ".json"); - } - -} diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java b/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java index 4b9ec56b41..b71ec7a534 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/MapKeycloakTransaction.java @@ -35,7 +35,7 @@ public class MapKeycloakTransaction implements KeycloakTransaction { @Override protected MapTaskWithValue taskFor(K key, V value) { return new MapTaskWithValue(value) { - @Override public void execute(MapStorage map) { map.putIfAbsent(key, getValue()); } + @Override public void execute(MapStorage map) { map.create(key, getValue()); } @Override public MapOperation getOperation() { return CREATE; } }; } @@ -44,7 +44,7 @@ public class MapKeycloakTransaction implements KeycloakTransaction { @Override protected MapTaskWithValue taskFor(K key, V value) { return new MapTaskWithValue(value) { - @Override public void execute(MapStorage map) { map.put(key, getValue()); } + @Override public void execute(MapStorage map) { map.update(key, getValue()); } @Override public MapOperation getOperation() { return UPDATE; } }; } @@ -53,7 +53,7 @@ public class MapKeycloakTransaction implements KeycloakTransaction { @Override protected MapTaskWithValue taskFor(K key, V value) { return new MapTaskWithValue(null) { - @Override public void execute(MapStorage map) { map.remove(key); } + @Override public void execute(MapStorage map) { map.delete(key); } @Override public MapOperation getOperation() { return DELETE; } }; } @@ -115,14 +115,14 @@ public class MapKeycloakTransaction implements KeycloakTransaction { * Adds a given task if not exists for the given key */ private void addTask(MapOperation op, K key, V value) { - log.tracef("Adding operation %s for %s @ %08x", op, key, System.identityHashCode(value)); + log.tracef("Adding operation %s for %s @ %08x", op, key, System.identityHashCode(value)); K taskKey = key; tasks.merge(taskKey, op.taskFor(key, value), MapTaskCompose::new); } // This is for possibility to lookup for session by id, which was created in this transaction - public V get(K key, Function defaultValueFunc) { + public V read(K key, Function defaultValueFunc) { MapTaskWithValue current = tasks.get(key); if (current != null) { return current.getValue(); @@ -140,23 +140,23 @@ public class MapKeycloakTransaction implements KeycloakTransaction { return keyDefaultValue.getValue(); } - public void put(K key, V value) { + public void update(K key, V value) { addTask(MapOperation.UPDATE, key, value); } - public void putIfAbsent(K key, V value) { + public void create(K key, V value) { addTask(MapOperation.CREATE, key, value); } - public void putIfChanged(K key, V value, Predicate shouldPut) { - log.tracef("Adding operation UPDATE_IF_CHANGED for %s @ %08x", key, System.identityHashCode(value)); + public void updateIfChanged(K key, V value, Predicate shouldPut) { + log.tracef("Adding operation UPDATE_IF_CHANGED for %s @ %08x", key, System.identityHashCode(value)); K taskKey = key; MapTaskWithValue op = new MapTaskWithValue(value) { @Override public void execute(MapStorage map) { if (shouldPut.test(getValue())) { - map.put(key, getValue()); + map.update(key, getValue()); } } @Override public MapOperation getOperation() { return MapOperation.UPDATE; } @@ -164,7 +164,7 @@ public class MapKeycloakTransaction implements KeycloakTransaction { tasks.merge(taskKey, op, MapKeycloakTransaction::merge); } - public void remove(K key) { + public void delete(K key) { addTask(MapOperation.DELETE, key, null); } diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/MapStorage.java b/model/map/src/main/java/org/keycloak/models/map/storage/MapStorage.java index 2e25fd4ffe..f09d74a163 100644 --- a/model/map/src/main/java/org/keycloak/models/map/storage/MapStorage.java +++ b/model/map/src/main/java/org/keycloak/models/map/storage/MapStorage.java @@ -18,6 +18,7 @@ package org.keycloak.models.map.storage; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; /** * @@ -25,14 +26,60 @@ import java.util.Set; */ public interface MapStorage { - V get(K key); + /** + * Creates an object in the store identified by given {@code key}. + * @param key Key of the object as seen in the logical level + * @param value Entity + * @return Reference to the entity created in the store + * @throws NullPointerException if object or its {@code id} is {@code null} + */ + V create(K key, V value); - V put(K key, V value); + /** + * Returns object with the given {@code key} from the storage or {@code null} if object does not exist. + * @param key Must not be {@code null}. + * @return See description + */ + V read(K key); - V putIfAbsent(K key, V value); + /** + * Returns stream of objects satisfying given {@code criteria} from the storage. + * The criteria are specified in the given criteria builder based on model properties. + * + * @param criteria + * @return Stream of objects. Never returns {@code null}. + */ + Stream read(ModelCriteriaBuilder criteria); - V remove(K key); + /** + * Updates the object with the given {@code id} in the storage if it already exists. + * @param id + * @throws NullPointerException if object or its {@code id} is {@code null} + */ + V update(K key, V value); + /** + * Deletes object with the given {@code key} from the storage, if exists, no-op otherwise. + * @param key + */ + V delete(K key); + + /** + * Returns criteria builder for the storage engine. + * The criteria are specified in the given criteria builder based on model properties. + *
+ * Note: While the criteria are formulated in terms of model properties, + * the storage engine may in turn process them into the best form that suits the + * underlying storage engine query language, e.g. to conditions on storage + * attributes or REST query parameters. + * If possible, do not delay filtering after the models are reconstructed from + * storage entities, in most cases this would be highly inefficient. + * + * @return See description + */ + ModelCriteriaBuilder createCriteriaBuilder(); + + @Deprecated Set> entrySet(); } diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/ModelCriteriaBuilder.java b/model/map/src/main/java/org/keycloak/models/map/storage/ModelCriteriaBuilder.java new file mode 100644 index 0000000000..2ae62db489 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/ModelCriteriaBuilder.java @@ -0,0 +1,176 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage; + +import org.keycloak.storage.SearchableModelField; + +/** + * Builder for criteria that can be used to limit results obtained from the store. + * This class is used for similar purpose as e.g. JPA's {@code CriteriaBuilder}, + * however it is much simpler version as it is tailored to very specific needs + * of future Keycloak store. + *

+ * Implementations are expected to be immutable. The expected use is like this: + *

+ * cb = storage.getCriteriaBuilder();
+ * storage.read(
+ *   cb.or(
+ *     cb.compare(FIELD1, EQ, 1).compare(FIELD2, EQ, 2),
+ *     cb.compare(FIELD1, EQ, 3).compare(FIELD2, EQ, 4)
+ *   )
+ * );
+ * 
+ * The above code should read items where + * {@code (FIELD1 == 1 && FIELD2 == 2) || (FIELD1 == 3 && FIELD2 == 4)}. + * + *

+ * It is equivalent to this: + *

+ * cb = storage.getCriteriaBuilder();
+ * storage.read(
+ *   cb.or(
+ *     cb.and(cb.compare(FIELD1, EQ, 1), cb.compare(FIELD2, EQ, 2)),
+ *     cb.and(cb.compare(FIELD1, EQ, 3), cb.compare(FIELD2, EQ, 4))
+ *   )
+ * );
+ * 
+ * + * @author hmlnarik + */ +public interface ModelCriteriaBuilder { + + /** + * The operators are very basic ones for this use case. In the real scenario, + * new operators can be added, possibly with different arity, e.g. {@code IN}. + * The {@link ModelCriteriaBuilder#compare} method would need an adjustment + * then, likely to taky vararg {@code value} instead of single value as it + * is now. + */ + public enum Operator { + /** Equals to */ + EQ, + /** Not equals to */ + NE, + /** Less than */ + LT, + /** Less than or equal */ + LE, + /** Greater than */ + GT, + /** Greater than or equal */ + GE, + /** Similar to SQL case-sensitive LIKE Whole string is matched. + * Percent sign means "any characters", question mark means "any single character": + *
    + *
  • {@code field LIKE "abc"} means value of the field {@code field} must match exactly {@code abc}
  • + *
  • {@code field LIKE "abc%"} means value of the field {@code field} must start with {@code abc}
  • + *
  • {@code field LIKE "%abc"} means value of the field {@code field} must end with {@code abc}
  • + *
  • {@code field LIKE "%abc%"} means value of the field {@code field} must contain {@code abc}
  • + *
+ */ + LIKE, + /** + * Similar to SQL case-insensitive LIKE. Whole string is matched. + * Percent sign means "any characters", question mark means "any single character": + *
    + *
  • {@code field ILIKE "abc"} means value of the field {@code field} must match exactly {@code abc}, {@code ABC}, {@code aBc} etc.
  • + *
  • {@code field ILIKE "abc%"} means value of the field {@code field} must start with {@code abc}, {@code ABC}, {@code aBc} etc.
  • + *
  • {@code field ILIKE "%abc"} means value of the field {@code field} must end with {@code abc}, {@code ABC}, {@code aBc} etc.
  • + *
  • {@code field ILIKE "%abc%"} means value of the field {@code field} must contain {@code abc}, {@code ABC}, {@code aBc} etc.
  • + *
+ */ + ILIKE, + /** Operator for belonging into a set of values */ + IN + } + + /** + * Adds a constraint for the given model field to this criteria builder + * and returns a criteria builder that is combined with the the new constraint. + * The resulting constraint is a logical conjunction (i.e. AND) of the original + * constraint present in this {@link ModelCriteriaBuilder} and the given operator. + * + * @param modelField Field on the logical model to be constrained + * @param op Operator + * @param value Additional operands of the operator. + * @return + */ + ModelCriteriaBuilder compare(SearchableModelField modelField, Operator op, Object... value); + + /** + * Creates and returns a new instance of {@code ModelCriteriaBuilder} that + * combines the given builders with the Boolean AND operator. + *

+ * Predicate coming out of {@code and} on an empty array of {@code builders} + * (i.e. empty conjunction) is always {@code true}. + * + *

+     *   cb = storage.getCriteriaBuilder();
+     *   storage.read(cb.or(
+     *     cb.and(cb.compare(FIELD1, EQ, 1), cb.compare(FIELD2, EQ, 2)),
+     *     cb.and(cb.compare(FIELD1, EQ, 3), cb.compare(FIELD2, EQ, 4))
+     *   );
+     * 
+ * + */ + ModelCriteriaBuilder and(ModelCriteriaBuilder... builders); + + /** + * Creates and returns a new instance of {@code ModelCriteriaBuilder} that + * combines the given builders with the Boolean OR operator. + *

+ * Predicate coming out of {@code and} on an empty array of {@code builders} + * (i.e. empty disjunction) is always {@code false}. + * + *

+     *   cb = storage.getCriteriaBuilder();
+     *   storage.read(cb.or(
+     *     cb.compare(FIELD1, EQ, 1).compare(FIELD2, EQ, 2),
+     *     cb.compare(FIELD1, EQ, 3).compare(FIELD2, EQ, 4)
+     *   );
+     * 
+ */ + ModelCriteriaBuilder or(ModelCriteriaBuilder... builders); + + /** + * Creates and returns a new instance of {@code ModelCriteriaBuilder} that + * negates the given builder. + *

+ * Note that if the {@code builder} has no condition yet, there is nothing + * to negate: empty negation is always {@code true}. + * + * @param builder + * @return + */ + ModelCriteriaBuilder not(ModelCriteriaBuilder builder); + + /** + * Returns this object cast to the given class. + * @param + * @param clazz + * @return + * @throws ClassCastException When this instance cannot be converted to the given {@code clazz}. + */ + default T unwrap(Class clazz) { + if (clazz.isInstance(this)) { + return clazz.cast(this); + } else { + throw new ClassCastException("Incompatible class: " + clazz); + } + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/chm/ConcurrentHashMapStorageProvider.java b/model/map/src/main/java/org/keycloak/models/map/storage/chm/ConcurrentHashMapStorageProvider.java new file mode 100644 index 0000000000..5af465d481 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/chm/ConcurrentHashMapStorageProvider.java @@ -0,0 +1,247 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.map.storage; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.map.common.AbstractEntity; +import org.keycloak.models.map.common.Serialization; +import org.keycloak.storage.SearchableModelField; +import com.fasterxml.jackson.databind.JavaType; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.jboss.logging.Logger; + +/** + * + * @author hmlnarik + */ +public class ConcurrentHashMapStorageProvider implements MapStorageProvider { + + public static class ConcurrentHashMapStorage implements MapStorage { + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public V create(K key, V value) { + return store.putIfAbsent(key, value); + } + + @Override + public V read(K key) { + return store.get(key); + } + + @Override + public V update(K key, V value) { + return store.replace(key, value); + } + + @Override + public V delete(K key) { + return store.remove(key); + } + + @Override + public ModelCriteriaBuilder createCriteriaBuilder() { + return new MapModelCriteriaBuilder(null); + } + + @Override + public Set> entrySet() { + return store.entrySet(); + } + + @Override + public Stream read(ModelCriteriaBuilder criteria) { + throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. + } + } + + private static class MapModelCriteriaBuilder implements ModelCriteriaBuilder { + + @FunctionalInterface + public interface TriConsumer,B,C> { A apply(A a, B b, C c); } + + private static final Predicate ALWAYS_TRUE = e -> true; + private static final Predicate ALWAYS_FALSE = e -> false; + + private final Predicate indexFilter; + private final Predicate modelFilter; + + private final Map, Operator, Object>> fieldPredicates; + + public MapModelCriteriaBuilder(Map, Operator, Object>> fieldPredicates) { + this(fieldPredicates, ALWAYS_TRUE, ALWAYS_TRUE); + } + + private MapModelCriteriaBuilder(Map, Operator, Object>> fieldPredicates, + Predicate indexReadFilter, Predicate sequentialReadFilter) { + this.fieldPredicates = fieldPredicates; + this.indexFilter = indexReadFilter; + this.modelFilter = sequentialReadFilter; + } + + @Override + public ModelCriteriaBuilder compare(SearchableModelField modelField, Operator op, Object... value) { + throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. + } + + @Override + public MapModelCriteriaBuilder and(ModelCriteriaBuilder... builders) { + Predicate resIndexFilter = Stream.of(builders) + .map(MapModelCriteriaBuilder.class::cast) + .map(MapModelCriteriaBuilder::getIndexFilter) + .reduce(ALWAYS_TRUE, (p1, p2) -> p1.and(p2)); + Predicate resModelFilter = Stream.of(builders) + .map(MapModelCriteriaBuilder.class::cast) + .map(MapModelCriteriaBuilder::getModelFilter) + .reduce(ALWAYS_TRUE, (p1, p2) -> p1.and(p2)); + return new MapModelCriteriaBuilder<>(fieldPredicates, resIndexFilter, resModelFilter); + } + + @Override + public MapModelCriteriaBuilder or(ModelCriteriaBuilder... builders) { + Predicate resIndexFilter = Stream.of(builders) + .map(MapModelCriteriaBuilder.class::cast) + .map(MapModelCriteriaBuilder::getIndexFilter) + .reduce(ALWAYS_FALSE, (p1, p2) -> p1.or(p2)); + Predicate resModelFilter = Stream.of(builders) + .map(MapModelCriteriaBuilder.class::cast) + .map(MapModelCriteriaBuilder::getModelFilter) + .reduce(ALWAYS_FALSE, (p1, p2) -> p1.or(p2)); + return new MapModelCriteriaBuilder<>(fieldPredicates, resIndexFilter, resModelFilter); + } + + @Override + public MapModelCriteriaBuilder not(ModelCriteriaBuilder builder) { + MapModelCriteriaBuilder b = builder.unwrap(MapModelCriteriaBuilder.class); + Predicate resIndexFilter = b.getIndexFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : b.getIndexFilter().negate(); + Predicate resModelFilter = b.getModelFilter() == ALWAYS_TRUE ? ALWAYS_TRUE : b.getModelFilter().negate(); + return new MapModelCriteriaBuilder<>(fieldPredicates, resIndexFilter, resModelFilter); + } + + public Predicate getIndexFilter() { + return indexFilter; + } + + public Predicate getModelFilter() { + return modelFilter; + } + } + + private static final String PROVIDER_ID = "concurrenthashmap"; + + private static final Logger LOG = Logger.getLogger(ConcurrentHashMapStorageProvider.class); + + private final ConcurrentHashMap> storages = new ConcurrentHashMap<>(); + + private File storageDirectory; + + @Override + public MapStorageProvider create(KeycloakSession session) { + return this; + } + + @Override + public void init(Scope config) { + File f = new File(config.get("dir")); + try { + this.storageDirectory = f.exists() + ? f + : Files.createTempDirectory("storage-map-chm-").toFile(); + } catch (IOException ex) { + this.storageDirectory = null; + } + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + storages.forEach(this::storeMap); + } + + private void storeMap(String fileName, ConcurrentHashMapStorage store) { + if (fileName != null) { + File f = getFile(fileName); + try { + if (storageDirectory != null && storageDirectory.exists()) { + LOG.debugf("Storing contents to %s", f.getCanonicalPath()); + Serialization.MAPPER.writeValue(f, store.entrySet().stream().map(Map.Entry::getValue)); + } else { + LOG.debugf("Not storing contents of %s because directory %s does not exist", fileName, this.storageDirectory); + } + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + } + + private > ConcurrentHashMapStorage loadMap(String fileName, Class valueType, EnumSet flags) { + ConcurrentHashMapStorage store = new ConcurrentHashMapStorage<>(); + + if (! flags.contains(Flag.INITIALIZE_EMPTY)) { + final File f = getFile(fileName); + if (f != null && f.exists()) { + try { + LOG.debugf("Restoring contents from %s", f.getCanonicalPath()); + JavaType type = Serialization.MAPPER.getTypeFactory().constructCollectionType(List.class, valueType); + + List values = Serialization.MAPPER.readValue(f, type); + values.forEach((V mce) -> store.create(mce.getId(), mce)); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + } + + return store; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + @SuppressWarnings("unchecked") + public > ConcurrentHashMapStorage getStorage(String name, Class keyType, Class valueType, Flag... flags) { + EnumSet f = flags == null || flags.length == 0 ? EnumSet.noneOf(Flag.class) : EnumSet.of(flags[0], flags); + return (ConcurrentHashMapStorage) storages.computeIfAbsent(name, n -> loadMap(name, valueType, f)); + } + + private File getFile(String fileName) { + return storageDirectory == null + ? null + : new File(storageDirectory, "map-" + fileName + ".json"); + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java b/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java index c6a4cf04b2..b807322b43 100644 --- a/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java +++ b/model/map/src/main/java/org/keycloak/models/map/user/MapUserProvider.java @@ -86,8 +86,8 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor } private MapUserEntity registerEntityForChanges(MapUserEntity origEntity) { - MapUserEntity res = tx.get(origEntity.getId(), id -> Serialization.from(origEntity)); - tx.putIfChanged(origEntity.getId(), res, MapUserEntity::isUpdated); + MapUserEntity res = tx.read(origEntity.getId(), id -> Serialization.from(origEntity)); + tx.updateIfChanged(origEntity.getId(), res, MapUserEntity::isUpdated); return res; } @@ -134,7 +134,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor } private Optional getEntityById(RealmModel realm, UUID id) { - MapUserEntity mapUserEntity = tx.get(id, userStore::get); + MapUserEntity mapUserEntity = tx.read(id, userStore::read); if (mapUserEntity != null && entityRealmFilter(realm).test(mapUserEntity)) { return Optional.of(mapUserEntity); } @@ -148,7 +148,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor private Stream getNotRemovedUpdatedUsersStream() { Stream updatedAndNotRemovedUsersStream = userStore.entrySet().stream() - .map(tx::getUpdated) // If the group has been removed, tx.get will return null, otherwise it will return me.getValue() + .map(tx::getUpdated) // If the group has been removed, tx.read will return null, otherwise it will return me.getValue() .filter(Objects::nonNull); return Stream.concat(tx.createdValuesStream(), updatedAndNotRemovedUsersStream); } @@ -328,7 +328,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor final UUID entityId = id == null ? UUID.randomUUID() : UUID.fromString(id); - if (tx.get(entityId, userStore::get) != null) { + if (tx.read(entityId, userStore::read) != null) { throw new ModelDuplicateException("User exists: " + entityId); } @@ -336,7 +336,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor entity.setUsername(username.toLowerCase()); entity.setCreatedTimestamp(Time.currentTimeMillis()); - tx.putIfAbsent(entityId, entity); + tx.create(entityId, entity); final UserModel userModel = entityToAdapterFunc(realm).apply(entity); if (addDefaultRoles) { @@ -362,7 +362,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor LOG.tracef("preRemove[RealmModel](%s)%s", realm, getShortStackTrace()); getUnsortedUserEntitiesStream(realm) .map(MapUserEntity::getId) - .forEach(tx::remove); + .forEach(tx::delete); } @Override @@ -371,7 +371,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor getUnsortedUserEntitiesStream(realm) .filter(userEntity -> Objects.equals(userEntity.getFederationLink(), storageProviderId)) .map(MapUserEntity::getId) - .forEach(tx::remove); + .forEach(tx::delete); } @Override @@ -712,7 +712,7 @@ public class MapUserProvider implements UserProvider.Streams, UserCredentialStor String userId = user.getId(); Optional userById = getEntityById(realm, userId); if (userById.isPresent()) { - tx.remove(UUID.fromString(userId)); + tx.delete(UUID.fromString(userId)); return true; } diff --git a/model/map/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProvider b/model/map/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProvider index 55ae0f6613..cc4212978f 100644 --- a/model/map/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProvider +++ b/model/map/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProvider @@ -15,4 +15,4 @@ # limitations under the License. # -org.keycloak.models.map.storage.ConcurrentHashMapStorageProvider +org.keycloak.models.map.storage.chm.ConcurrentHashMapStorageProvider diff --git a/server-spi/src/main/java/org/keycloak/storage/SearchableModelField.java b/server-spi/src/main/java/org/keycloak/storage/SearchableModelField.java new file mode 100644 index 0000000000..e04700d4f9 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/storage/SearchableModelField.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.storage; + +/** + * + * @author hmlnarik + */ +public class SearchableModelField { + + private final String name; + private final Class fieldClass; + + public SearchableModelField(String name, Class fieldClass) { + this.name = name; + this.fieldClass = fieldClass; + } + + public String getName() { + return this.name; + } + + public Class getFieldType() { + return fieldClass; + } +} diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java index a9ce346325..559233c5c2 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java @@ -20,7 +20,7 @@ import org.keycloak.testsuite.model.KeycloakModelParameters; import org.keycloak.models.map.client.MapClientProviderFactory; import org.keycloak.models.map.group.MapGroupProviderFactory; import org.keycloak.models.map.role.MapRoleProviderFactory; -import org.keycloak.models.map.storage.ConcurrentHashMapStorageProvider; +import org.keycloak.models.map.storage.chm.ConcurrentHashMapStorageProvider; import org.keycloak.models.map.storage.MapStorageProvider; import org.keycloak.models.map.storage.MapStorageSpi; import org.keycloak.provider.ProviderFactory;