Added ModelIllegalStateException to handle lazy loading exception.

Closes #9645
This commit is contained in:
Alexander Schwartz 2022-01-24 14:59:34 +01:00 committed by Hynek Mlnařík
parent 64cbbde7cf
commit df7ddbf9b3
14 changed files with 236 additions and 70 deletions

View file

@ -0,0 +1,52 @@
/*
* Copyright 2022. Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.map.storage.jpa;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.models.map.common.AbstractEntity;
/**
* Base class for all delegate providers for the JPA storage.
*
* Wraps the delegate so that it can be safely updated during lazy loading.
*/
public abstract class JpaDelegateProvider<T extends JpaRootEntity & AbstractEntity> {
private T delegate;
protected JpaDelegateProvider(T delegate) {
this.delegate = delegate;
}
protected T getDelegate() {
return delegate;
}
/**
* Validates the new entity.
*
* Will throw {@link ModelIllegalStateException} if the entity has been deleted or changed in the meantime.
*/
protected void setDelegate(T newDelegate) {
if (newDelegate == null) {
throw new ModelIllegalStateException("Unable to retrieve entity: " + delegate.getClass().getName() + "#" + delegate.getId());
}
if (newDelegate.getVersion() != delegate.getVersion()) {
throw new ModelIllegalStateException("Version of entity changed between two loads: " + delegate.getClass().getName() + "#" + delegate.getId());
}
this.delegate = newDelegate;
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.map.storage.jpa;
/**
* Interface for all root entities in the JPA storage.
*/
public interface JpaRootEntity {
/**
* Version of the JPA entity used for optimistic locking
*/
int getVersion();
}

View file

@ -76,6 +76,7 @@ public class JpaClientMapKeycloakTransaction extends JpaKeycloakTransaction impl
Root<JpaClientEntity> root = query.from(JpaClientEntity.class);
query.select(cb.construct(JpaClientEntity.class,
root.get("id"),
root.get("version"),
root.get("entityVersion"),
root.get("realmId"),
root.get("clientId"),

View file

@ -28,21 +28,21 @@ import org.keycloak.models.map.client.MapClientEntity;
import org.keycloak.models.map.client.MapClientEntityFields;
import org.keycloak.models.map.common.EntityField;
import org.keycloak.models.map.common.delegate.DelegateProvider;
import org.keycloak.models.map.storage.jpa.JpaDelegateProvider;
import org.keycloak.models.map.storage.jpa.client.entity.JpaClientEntity;
public class JpaClientDelegateProvider implements DelegateProvider<MapClientEntity> {
public class JpaClientDelegateProvider extends JpaDelegateProvider<JpaClientEntity> implements DelegateProvider<MapClientEntity> {
private JpaClientEntity delegate;
private final EntityManager em;
public JpaClientDelegateProvider(JpaClientEntity delegate, EntityManager em) {
this.delegate = delegate;
super(delegate);
this.em = em;
}
@Override
public MapClientEntity getDelegate(boolean isRead, Enum<? extends EntityField<MapClientEntity>> field, Object... parameters) {
if (delegate.isMetadataInitialized()) return delegate;
if (getDelegate().isMetadataInitialized()) return getDelegate();
if (isRead) {
if (field instanceof MapClientEntityFields) {
switch ((MapClientEntityFields) field) {
@ -51,26 +51,26 @@ public class JpaClientDelegateProvider implements DelegateProvider<MapClientEnti
case CLIENT_ID:
case PROTOCOL:
case ENABLED:
return delegate;
return getDelegate();
case ATTRIBUTES:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<JpaClientEntity> query = cb.createQuery(JpaClientEntity.class);
Root<JpaClientEntity> root = query.from(JpaClientEntity.class);
root.fetch("attributes", JoinType.INNER);
query.select(root).where(cb.equal(root.get("id"), UUID.fromString(delegate.getId())));
query.select(root).where(cb.equal(root.get("id"), UUID.fromString(getDelegate().getId())));
delegate = em.createQuery(query).getSingleResult();
setDelegate(em.createQuery(query).getSingleResult());
break;
default:
delegate = em.find(JpaClientEntity.class, UUID.fromString(delegate.getId()));
setDelegate(em.find(JpaClientEntity.class, UUID.fromString(getDelegate().getId())));
}
} else throw new IllegalStateException("Not a valid client field: " + field);
} else {
delegate = em.find(JpaClientEntity.class, UUID.fromString(delegate.getId()));
setDelegate(em.find(JpaClientEntity.class, UUID.fromString(getDelegate().getId())));
}
return delegate;
return getDelegate();
}
}

View file

@ -45,6 +45,8 @@ import org.keycloak.models.map.client.MapClientEntity.AbstractClientEntity;
import org.keycloak.models.map.client.MapProtocolMapperEntity;
import org.keycloak.models.map.common.DeepCloner;
import static org.keycloak.models.map.storage.jpa.Constants.SUPPORTED_VERSION_CLIENT;
import org.keycloak.models.map.storage.jpa.JpaRootEntity;
import org.keycloak.models.map.storage.jpa.hibernate.jsonb.JsonbType;
/**
@ -60,7 +62,7 @@ import org.keycloak.models.map.storage.jpa.hibernate.jsonb.JsonbType;
)
})
@TypeDefs({@TypeDef(name = "jsonb", typeClass = JsonbType.class)})
public class JpaClientEntity extends AbstractClientEntity implements Serializable {
public class JpaClientEntity extends AbstractClientEntity implements Serializable, JpaRootEntity {
@Id
@Column
@ -113,9 +115,10 @@ public class JpaClientEntity extends AbstractClientEntity implements Serializabl
* Used by hibernate when calling cb.construct from read(QueryParameters) method.
* It is used to select client without metadata(json) field.
*/
public JpaClientEntity(UUID id, Integer entityVersion, String realmId, String clientId,
public JpaClientEntity(UUID id, int version, Integer entityVersion, String realmId, String clientId,
String protocol, Boolean enabled) {
this.id = id;
this.version = version;
this.entityVersion = entityVersion;
this.realmId = realmId;
this.clientId = clientId;
@ -148,6 +151,7 @@ public class JpaClientEntity extends AbstractClientEntity implements Serializabl
metadata.setEntityVersion(entityVersion);
}
@Override
public int getVersion() {
return version;
}

View file

@ -76,6 +76,7 @@ public class JpaRoleMapKeycloakTransaction extends JpaKeycloakTransaction implem
Root<JpaRoleEntity> root = query.from(JpaRoleEntity.class);
query.select(cb.construct(JpaRoleEntity.class,
root.get("id"),
root.get("version"),
root.get("entityVersion"),
root.get("realmId"),
root.get("clientId"),

View file

@ -26,21 +26,21 @@ import org.keycloak.models.map.common.EntityField;
import org.keycloak.models.map.common.delegate.DelegateProvider;
import org.keycloak.models.map.role.MapRoleEntity;
import org.keycloak.models.map.role.MapRoleEntityFields;
import org.keycloak.models.map.storage.jpa.JpaDelegateProvider;
import org.keycloak.models.map.storage.jpa.role.entity.JpaRoleEntity;
public class JpaRoleDelegateProvider implements DelegateProvider<MapRoleEntity> {
public class JpaRoleDelegateProvider extends JpaDelegateProvider<JpaRoleEntity> implements DelegateProvider<MapRoleEntity> {
private JpaRoleEntity delegate;
private final EntityManager em;
public JpaRoleDelegateProvider(JpaRoleEntity delegate, EntityManager em) {
this.delegate = delegate;
super(delegate);
this.em = em;
}
@Override
public JpaRoleEntity getDelegate(boolean isRead, Enum<? extends EntityField<MapRoleEntity>> field, Object... parameters) {
if (delegate.isMetadataInitialized()) return delegate;
if (getDelegate().isMetadataInitialized()) return getDelegate();
if (isRead) {
if (field instanceof MapRoleEntityFields) {
switch ((MapRoleEntityFields) field) {
@ -49,26 +49,26 @@ public class JpaRoleDelegateProvider implements DelegateProvider<MapRoleEntity>
case CLIENT_ID:
case NAME:
case DESCRIPTION:
return delegate;
return getDelegate();
case ATTRIBUTES:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<JpaRoleEntity> query = cb.createQuery(JpaRoleEntity.class);
Root<JpaRoleEntity> root = query.from(JpaRoleEntity.class);
root.fetch("attributes", JoinType.INNER);
query.select(root).where(cb.equal(root.get("id"), UUID.fromString(delegate.getId())));
query.select(root).where(cb.equal(root.get("id"), UUID.fromString(getDelegate().getId())));
delegate = em.createQuery(query).getSingleResult();
setDelegate(em.createQuery(query).getSingleResult());
break;
default:
delegate = em.find(JpaRoleEntity.class, UUID.fromString(delegate.getId()));
setDelegate(em.find(JpaRoleEntity.class, UUID.fromString(getDelegate().getId())));
}
} else throw new IllegalStateException("Not a valid role field: " + field);
} else {
delegate = em.find(JpaRoleEntity.class, UUID.fromString(delegate.getId()));
setDelegate(em.find(JpaRoleEntity.class, UUID.fromString(getDelegate().getId())));
}
return delegate;
return getDelegate();
}
}

View file

@ -43,6 +43,8 @@ import org.hibernate.annotations.TypeDefs;
import org.keycloak.models.map.common.DeepCloner;
import org.keycloak.models.map.role.MapRoleEntity.AbstractRoleEntity;
import static org.keycloak.models.map.storage.jpa.Constants.SUPPORTED_VERSION_ROLE;
import org.keycloak.models.map.storage.jpa.JpaRootEntity;
import org.keycloak.models.map.storage.jpa.hibernate.jsonb.JsonbType;
/**
@ -53,7 +55,7 @@ import org.keycloak.models.map.storage.jpa.hibernate.jsonb.JsonbType;
@Entity
@Table(name = "role", uniqueConstraints = {@UniqueConstraint(columnNames = {"realmId", "clientId", "name"})})
@TypeDefs({@TypeDef(name = "jsonb", typeClass = JsonbType.class)})
public class JpaRoleEntity extends AbstractRoleEntity implements Serializable {
public class JpaRoleEntity extends AbstractRoleEntity implements Serializable, JpaRootEntity {
@Id
@Column
@ -106,8 +108,9 @@ public class JpaRoleEntity extends AbstractRoleEntity implements Serializable {
* Used by hibernate when calling cb.construct from read(QueryParameters) method.
* It is used to select role without metadata(json) field.
*/
public JpaRoleEntity(UUID id, Integer entityVersion, String realmId, String clientId, String name, String description) {
public JpaRoleEntity(UUID id, int version, Integer entityVersion, String realmId, String clientId, String name, String description) {
this.id = id;
this.version = version;
this.entityVersion = entityVersion;
this.realmId = realmId;
this.clientId = clientId;
@ -140,6 +143,7 @@ public class JpaRoleEntity extends AbstractRoleEntity implements Serializable {
metadata.setEntityVersion(entityVersion);
}
@Override
public int getVersion() {
return version;
}

View file

@ -19,6 +19,7 @@ package org.keycloak.models.delegate;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
@ -83,7 +84,7 @@ public class ClientModelLazyDelegate implements ClientModel {
}
ClientModel ref = delegate.getReference();
if (ref == null) {
throw new IllegalStateException("Invalid delegate obtained");
throw new ModelIllegalStateException("Invalid delegate obtained");
}
return ref;
}

View file

@ -17,6 +17,7 @@
package org.keycloak.models.utils;
import org.jboss.logging.Logger;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.model.PermissionTicket;
import org.keycloak.authorization.model.Policy;
@ -46,8 +47,10 @@ import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -101,6 +104,7 @@ public class ModelToRepresentation {
REALM_EXCLUDED_ATTRIBUTES.add(Constants.CLIENT_PROFILES);
}
private static final Logger LOG = Logger.getLogger(ModelToRepresentation.class);
public static void buildGroupPath(StringBuilder sb, GroupModel group) {
if (group.getParent() != null) {
@ -554,6 +558,25 @@ public class ModelToRepresentation {
return rep;
}
/**
* Handles exceptions that occur when transforming the model to a representation and will remove
* all null objects from the stream.
*
* Entities that have been removed from the store or where a lazy loading exception occurs will not show up
* in the output stream.
*/
public static <M, R> Stream<R> filterValidRepresentations(Stream<M> models, Function<M, R> transformer) {
return models.map(m -> {
try {
return transformer.apply(m);
} catch (ModelIllegalStateException e) {
LOG.warn("unable to retrieve model information, skipping entity", e);
return null;
}
})
.filter(Objects::nonNull);
}
public static CredentialRepresentation toRepresentation(UserCredentialModel cred) {
CredentialRepresentation rep = new CredentialRepresentation();
rep.setType(CredentialRepresentation.SECRET);

View file

@ -0,0 +1,49 @@
/*
* Copyright 2022 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;
import java.util.function.Function;
import java.util.stream.Stream;
/**
* Thrown when data can't be retrieved for the model.
*
* This occurs when an entity has been removed or updated in the meantime. This might wrap an optimistic lock exception
* depending on the store.
*
* Callers might use this exception to filter out entities that are in an illegal state, see
* <code>org.keycloak.models.utils.ModelToRepresentation#toRepresentation(Stream, Function)</code>
*
* @author <a href="mailto:aschwart@redhat.com">Alexander Schwartz</a>
*/
public class ModelIllegalStateException extends ModelException {
public ModelIllegalStateException() {
}
public ModelIllegalStateException(String message) {
super(message);
}
public ModelIllegalStateException(String message, Throwable cause) {
super(message, cause);
}
public ModelIllegalStateException(Throwable cause) {
super(cause);
}
}

View file

@ -17,22 +17,11 @@
package org.keycloak.exportimport.util;
import static org.keycloak.models.utils.ModelToRepresentation.toRepresentation;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.AuthorizationProviderFactory;
import org.keycloak.authorization.model.Policy;
@ -71,11 +60,20 @@ import org.keycloak.representations.idm.authorization.ResourceServerRepresentati
import org.keycloak.representations.idm.authorization.ScopeRepresentation;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.models.utils.ModelToRepresentation.toRepresentation;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -103,15 +101,17 @@ public class ExportUtils {
.map(ClientScopeModel::getName).collect(Collectors.toList()));
// Clients
List<ClientModel> clients = Collections.emptyList();
List<ClientModel> clients = new LinkedList<>();
if (options.isClientsIncluded()) {
clients = realm.getClientsStream()
.filter(c -> { try { c.getClientId(); return true; } catch (Exception ex) { return false; } } )
.collect(Collectors.toList());
List<ClientRepresentation> clientReps = clients.stream()
.map(app -> exportClient(session, app))
.collect(Collectors.toList());
// we iterate over all clients in the stream.
// only those client models that can be translated into a valid client representation will be added to the client list
// that is later used to retrieve related information about groups and roles
List<ClientRepresentation> clientReps = ModelToRepresentation.filterValidRepresentations(realm.getClientsStream(), app -> {
ClientRepresentation clientRepresentation = exportClient(session, app);
clients.add(app);
return clientRepresentation;
}).collect(Collectors.toList());
rep.setClients(clientReps);
}

View file

@ -33,6 +33,7 @@ import org.keycloak.constants.AdapterConstants;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.LoginProtocol;
@ -241,15 +242,18 @@ public class ResourceAdminManager {
public GlobalRequestResult logoutAll(RealmModel realm) {
realm.setNotBefore(Time.currentTime());
Stream<ClientModel> resources = realm.getClientsStream()
.filter(c -> { try { c.getClientId(); return true; } catch (Exception ex) { return false; } } );
GlobalRequestResult finalResult = new GlobalRequestResult();
AtomicInteger counter = new AtomicInteger(0);
resources.forEach(r -> {
realm.getClientsStream().forEach(c -> {
try {
counter.getAndIncrement();
GlobalRequestResult currentResult = logoutClient(realm, r, realm.getNotBefore());
GlobalRequestResult currentResult = logoutClient(realm, c, realm.getNotBefore());
finalResult.addAll(currentResult);
} catch (ModelIllegalStateException ex) {
// currently, GlobalRequestResult doesn't allow for information about clients that we were unable to retrieve.
logger.warn("unable to retrieve client information for logout, skipping resource", ex);
}
});
logger.debugv("logging out {0} resources ", counter);

View file

@ -57,7 +57,6 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import static java.lang.Boolean.TRUE;
@ -87,9 +86,11 @@ public class ClientsResource {
}
/**
* Get clients belonging to the realm
* Get clients belonging to the realm.
*
* Returns a list of clients belonging to the realm
* If a client can't be retrieved from the storage due to a problem with the underlying storage,
* it is silently removed from the returned list.
* This ensures that concurrent modifications to the list don't prevent callers from retrieving this list.
*
* @param clientId filter by clientId
* @param viewableOnly filter clients that cannot be viewed in full by admin
@ -131,9 +132,8 @@ public class ClientsResource {
}
}
Stream<ClientRepresentation> s = clientModels
.filter(c -> { try { c.getClientId(); return true; } catch (Exception ex) { return false; } } )
.map(c -> {
Stream<ClientRepresentation> s = ModelToRepresentation.filterValidRepresentations(clientModels,
c -> {
ClientRepresentation representation = null;
if (canView || auth.clients().canView(c)) {
representation = ModelToRepresentation.toRepresentation(c, session);
@ -146,8 +146,7 @@ public class ClientsResource {
}
return representation;
})
.filter(Objects::nonNull);
});
if (!canView) {
s = paginatedStream(s, firstResult, maxResults);