From ac0011ab6fff58e709cf8caf82fb13474b739139 Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Mon, 22 Jun 2020 22:20:19 +0200 Subject: [PATCH] KEYCLOAK-14553 Client map store Co-Authored-By: vramik --- .../map/client/AbstractClientEntity.java | 508 ++++++++++++++++ .../map/client/AbstractClientModel.java | 111 ++++ .../models/map/client/MapClientAdapter.java | 540 ++++++++++++++++++ .../models/map/client/MapClientEntity.java | 35 ++ .../models/map/client/MapClientProvider.java | 310 ++++++++++ .../map/client/MapClientProviderFactory.java | 51 ++ .../models/map/common/AbstractEntity.java | 32 ++ .../common/AbstractMapProviderFactory.java | 51 ++ .../models/map/common/Serialization.java | 61 ++ .../ConcurrentHashMapStorageProvider.java | 132 +++++ .../map/storage/MapStorageProvider.java | 43 ++ .../models/map/storage/MapStorageSpi.java | 51 ++ .../org.keycloak.models.ClientProviderFactory | 18 + ...loak.models.map.storage.MapStorageProvider | 18 + .../services/org.keycloak.provider.Spi | 18 + .../models/ClientProviderFactory.java | 23 + .../java/org/keycloak/models/ClientSpi.java | 46 ++ .../services/org.keycloak.provider.Spi | 1 + .../java/org/keycloak/models/ClientModel.java | 5 + .../keycloak/models/RoleContainerModel.java | 25 +- .../keycloak/models/ScopeContainerModel.java | 20 +- .../integration-arquillian/tests/base/pom.xml | 26 +- .../testsuite/admin/realm/RealmTest.java | 5 +- .../resources/META-INF/keycloak-server.json | 11 + .../resources/META-INF/keycloak-server.json | 11 + 25 files changed, 2145 insertions(+), 7 deletions(-) create mode 100644 model/map/src/main/java/org/keycloak/models/map/client/AbstractClientEntity.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/client/AbstractClientModel.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/client/MapClientAdapter.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/client/MapClientEntity.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/client/MapClientProviderFactory.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/common/AbstractEntity.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/common/AbstractMapProviderFactory.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/common/Serialization.java create 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/MapStorageProvider.java create mode 100644 model/map/src/main/java/org/keycloak/models/map/storage/MapStorageSpi.java create mode 100644 model/map/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory create mode 100644 model/map/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProvider create mode 100644 model/map/src/main/resources/META-INF/services/org.keycloak.provider.Spi create mode 100644 server-spi-private/src/main/java/org/keycloak/models/ClientProviderFactory.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/ClientSpi.java diff --git a/model/map/src/main/java/org/keycloak/models/map/client/AbstractClientEntity.java b/model/map/src/main/java/org/keycloak/models/map/client/AbstractClientEntity.java new file mode 100644 index 0000000000..5f43d6face --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/client/AbstractClientEntity.java @@ -0,0 +1,508 @@ +/* + * 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.client; + +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.map.common.AbstractEntity; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * + * @author hmlnarik + */ +public abstract class AbstractClientEntity implements AbstractEntity { + + private K id; + private String realmId; + + private String clientId; + private String name; + private String description; + private Set redirectUris = new HashSet<>(); + private boolean enabled; + private boolean alwaysDisplayInConsole; + private String clientAuthenticatorType; + private String secret; + private String registrationToken; + private String protocol; + private Map attributes = new HashMap<>(); + private Map authFlowBindings = new HashMap<>(); + private boolean publicClient; + private boolean fullScopeAllowed; + private boolean frontchannelLogout; + private int notBefore; + private Set scope = new HashSet<>(); + private Set webOrigins = new HashSet<>(); + private Map protocolMappers = new HashMap<>(); + private Map clientScopes = new HashMap<>(); + private Set scopeMappings = new LinkedHashSet<>(); + private List defaultRoles = new LinkedList<>(); + private boolean surrogateAuthRequired; + private String managementUrl; + private String rootUrl; + private String baseUrl; + private boolean bearerOnly; + private boolean consentRequired; + private boolean standardFlowEnabled; + private boolean implicitFlowEnabled; + private boolean directAccessGrantsEnabled; + private boolean serviceAccountsEnabled; + private int nodeReRegistrationTimeout; + + /** + * Flag signalizing that any of the setters has been meaningfully used. + */ + protected boolean updated; + + protected AbstractClientEntity() { + this.id = null; + this.realmId = null; + } + + public AbstractClientEntity(K id, String realmId) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(realmId, "realmId"); + + this.id = id; + this.realmId = realmId; + } + + @Override + public K getId() { + return this.id; + } + + @Override + public boolean isUpdated() { + return this.updated; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.updated |= ! Objects.equals(this.clientId, clientId); + this.clientId = clientId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.updated |= ! Objects.equals(this.name, name); + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.updated |= ! Objects.equals(this.description, description); + this.description = description; + } + + public Set getRedirectUris() { + return redirectUris; + } + + public void setRedirectUris(Set redirectUris) { + this.updated |= ! Objects.equals(this.redirectUris, redirectUris); + this.redirectUris = redirectUris; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.updated |= ! Objects.equals(this.enabled, enabled); + this.enabled = enabled; + } + + public boolean isAlwaysDisplayInConsole() { + return alwaysDisplayInConsole; + } + + public void setAlwaysDisplayInConsole(boolean alwaysDisplayInConsole) { + this.updated |= ! Objects.equals(this.alwaysDisplayInConsole, alwaysDisplayInConsole); + this.alwaysDisplayInConsole = alwaysDisplayInConsole; + } + + public String getClientAuthenticatorType() { + return clientAuthenticatorType; + } + + public void setClientAuthenticatorType(String clientAuthenticatorType) { + this.updated |= ! Objects.equals(this.clientAuthenticatorType, clientAuthenticatorType); + this.clientAuthenticatorType = clientAuthenticatorType; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.updated |= ! Objects.equals(this.secret, secret); + this.secret = secret; + } + + public String getRegistrationToken() { + return registrationToken; + } + + public void setRegistrationToken(String registrationToken) { + this.updated |= ! Objects.equals(this.registrationToken, registrationToken); + this.registrationToken = registrationToken; + } + + public String getProtocol() { + return protocol; + } + + public void setProtocol(String protocol) { + this.updated |= ! Objects.equals(this.protocol, protocol); + this.protocol = protocol; + } + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.updated |= ! Objects.equals(this.attributes, attributes); + this.attributes = attributes; + } + + public Map getAuthFlowBindings() { + return authFlowBindings; + } + + public void setAuthFlowBindings(Map authFlowBindings) { + this.updated |= ! Objects.equals(this.authFlowBindings, authFlowBindings); + this.authFlowBindings = authFlowBindings; + } + + public boolean isPublicClient() { + return publicClient; + } + + public void setPublicClient(boolean publicClient) { + this.updated |= ! Objects.equals(this.publicClient, publicClient); + this.publicClient = publicClient; + } + + public boolean isFullScopeAllowed() { + return fullScopeAllowed; + } + + public void setFullScopeAllowed(boolean fullScopeAllowed) { + this.updated |= ! Objects.equals(this.fullScopeAllowed, fullScopeAllowed); + this.fullScopeAllowed = fullScopeAllowed; + } + + public boolean isFrontchannelLogout() { + return frontchannelLogout; + } + + public void setFrontchannelLogout(boolean frontchannelLogout) { + this.updated |= ! Objects.equals(this.frontchannelLogout, frontchannelLogout); + this.frontchannelLogout = frontchannelLogout; + } + + public int getNotBefore() { + return notBefore; + } + + public void setNotBefore(int notBefore) { + this.updated |= ! Objects.equals(this.notBefore, notBefore); + this.notBefore = notBefore; + } + + public Set getScope() { + return scope; + } + + public void setScope(Set scope) { + this.updated |= ! Objects.equals(this.scope, scope); + this.scope.clear(); + this.scope.addAll(scope); + } + + public Set getWebOrigins() { + return webOrigins; + } + + public void setWebOrigins(Set webOrigins) { + this.updated |= ! Objects.equals(this.webOrigins, webOrigins); + this.webOrigins.clear(); + this.webOrigins.addAll(webOrigins); + } + + public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) { + Objects.requireNonNull(model.getId(), "protocolMapper.id"); + updated = true; + this.protocolMappers.put(model.getId(), model); + return model; + } + + public Collection getProtocolMappers() { + return protocolMappers.values(); + } + + public void updateProtocolMapper(String id, ProtocolMapperModel mapping) { + updated = true; + protocolMappers.put(id, mapping); + } + + public void removeProtocolMapper(String id) { + updated |= protocolMappers.remove(id) != null; + } + + public void setProtocolMappers(Collection protocolMappers) { + this.updated |= ! Objects.equals(this.protocolMappers, protocolMappers); + this.protocolMappers.clear(); + this.protocolMappers.putAll(protocolMappers.stream().collect(Collectors.toMap(ProtocolMapperModel::getId, Function.identity()))); + } + + public ProtocolMapperModel getProtocolMapperById(String id) { + return id == null ? null : protocolMappers.get(id); + } + + public boolean isSurrogateAuthRequired() { + return surrogateAuthRequired; + } + + public void setSurrogateAuthRequired(boolean surrogateAuthRequired) { + this.updated |= ! Objects.equals(this.surrogateAuthRequired, surrogateAuthRequired); + this.surrogateAuthRequired = surrogateAuthRequired; + } + + public String getManagementUrl() { + return managementUrl; + } + + public void setManagementUrl(String managementUrl) { + this.updated |= ! Objects.equals(this.managementUrl, managementUrl); + this.managementUrl = managementUrl; + } + + public String getRootUrl() { + return rootUrl; + } + + public void setRootUrl(String rootUrl) { + this.updated |= ! Objects.equals(this.rootUrl, rootUrl); + this.rootUrl = rootUrl; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.updated |= ! Objects.equals(this.baseUrl, baseUrl); + this.baseUrl = baseUrl; + } + + public List getDefaultRoles() { + return defaultRoles; + } + + public void setDefaultRoles(Collection defaultRoles) { + this.updated |= ! Objects.equals(this.defaultRoles, defaultRoles); + this.defaultRoles.clear(); + this.defaultRoles.addAll(defaultRoles); + } + + public void addDefaultRole(String name) { + updated = true; + if (name != null) { + defaultRoles.add(name); + } + } + + public void removeDefaultRoles(String... defaultRoles) { + for (String defaultRole : defaultRoles) { + updated |= this.defaultRoles.remove(defaultRole); + } + } + + public boolean isBearerOnly() { + return bearerOnly; + } + + public void setBearerOnly(boolean bearerOnly) { + this.updated |= ! Objects.equals(this.bearerOnly, bearerOnly); + this.bearerOnly = bearerOnly; + } + + public boolean isConsentRequired() { + return consentRequired; + } + + public void setConsentRequired(boolean consentRequired) { + this.updated |= ! Objects.equals(this.consentRequired, consentRequired); + this.consentRequired = consentRequired; + } + + public boolean isStandardFlowEnabled() { + return standardFlowEnabled; + } + + public void setStandardFlowEnabled(boolean standardFlowEnabled) { + this.updated |= ! Objects.equals(this.standardFlowEnabled, standardFlowEnabled); + this.standardFlowEnabled = standardFlowEnabled; + } + + public boolean isImplicitFlowEnabled() { + return implicitFlowEnabled; + } + + public void setImplicitFlowEnabled(boolean implicitFlowEnabled) { + this.updated |= ! Objects.equals(this.implicitFlowEnabled, implicitFlowEnabled); + this.implicitFlowEnabled = implicitFlowEnabled; + } + + public boolean isDirectAccessGrantsEnabled() { + return directAccessGrantsEnabled; + } + + public void setDirectAccessGrantsEnabled(boolean directAccessGrantsEnabled) { + this.updated |= ! Objects.equals(this.directAccessGrantsEnabled, directAccessGrantsEnabled); + this.directAccessGrantsEnabled = directAccessGrantsEnabled; + } + + public boolean isServiceAccountsEnabled() { + return serviceAccountsEnabled; + } + + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + this.updated |= ! Objects.equals(this.serviceAccountsEnabled, serviceAccountsEnabled); + this.serviceAccountsEnabled = serviceAccountsEnabled; + } + + public int getNodeReRegistrationTimeout() { + return nodeReRegistrationTimeout; + } + + public void setNodeReRegistrationTimeout(int nodeReRegistrationTimeout) { + this.updated |= ! Objects.equals(this.nodeReRegistrationTimeout, nodeReRegistrationTimeout); + this.nodeReRegistrationTimeout = nodeReRegistrationTimeout; + } + + public void addWebOrigin(String webOrigin) { + updated = true; + this.webOrigins.add(webOrigin); + } + + public void removeWebOrigin(String webOrigin) { + updated |= this.webOrigins.remove(webOrigin); + } + + public void addRedirectUri(String redirectUri) { + this.updated |= ! this.redirectUris.contains(redirectUri); + this.redirectUris.add(redirectUri); + } + + public void removeRedirectUri(String redirectUri) { + updated |= this.redirectUris.remove(redirectUri); + } + + public void setAttribute(String name, String value) { + this.updated = true; + this.attributes.put(name, value); + } + + public void removeAttribute(String name) { + this.updated |= this.attributes.remove(name) != null; + } + + public String getAttribute(String name) { + return this.attributes.get(name); + } + + public String getAuthenticationFlowBindingOverride(String binding) { + return this.authFlowBindings.get(binding); + } + + public Map getAuthenticationFlowBindingOverrides() { + return this.authFlowBindings; + } + + public void removeAuthenticationFlowBindingOverride(String binding) { + updated |= this.authFlowBindings.remove(binding) != null; + } + + public void setAuthenticationFlowBindingOverride(String binding, String flowId) { + this.updated = true; + this.authFlowBindings.put(binding, flowId); + } + + public Collection getScopeMappings() { + return scopeMappings; + } + + public void addScopeMapping(String id) { + if (id != null) { + updated = true; + scopeMappings.add(id); + } + } + + public void deleteScopeMapping(String id) { + updated |= scopeMappings.remove(id); + } + + public void addClientScope(String id, boolean defaultScope) { + if (id != null) { + updated = true; + this.clientScopes.put(id, defaultScope); + } + } + + public void removeClientScope(String id) { + if (id != null) { + updated |= clientScopes.remove(id) != null; + } + } + + public Stream getClientScopes(boolean defaultScope) { + return this.clientScopes.entrySet().stream() + .filter(me -> Objects.equals(me.getValue(), defaultScope)) + .map(Entry::getKey); + } + + public String getRealmId() { + return this.realmId; + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/client/AbstractClientModel.java b/model/map/src/main/java/org/keycloak/models/map/client/AbstractClientModel.java new file mode 100644 index 0000000000..b5664e2153 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/client/AbstractClientModel.java @@ -0,0 +1,111 @@ +/* + * 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.client; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.map.common.AbstractEntity; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * + * @author hmlnarik + */ +public abstract class AbstractClientModel implements ClientModel { + + protected final KeycloakSession session; + protected final RealmModel realm; + protected final E entity; + + public AbstractClientModel(KeycloakSession session, RealmModel realm, E entity) { + Objects.requireNonNull(entity, "entity"); + Objects.requireNonNull(realm, "realm"); + + this.session = session; + this.realm = realm; + this.entity = entity; + } + + @Override + public void addClientScopes(Set clientScopes, boolean defaultScope) { + for (ClientScopeModel cs : clientScopes) { + addClientScope(cs, defaultScope); + } + } + + @Override + public Set getRealmScopeMappings() { + String realmId = realm.getId(); + return getScopeMappingsStream() + .filter(rm -> Objects.equals(rm.getContainerId(), realmId)) + .collect(Collectors.toSet()); + } + + @Override + public RoleModel getRole(String name) { + return session.realms().getClientRole(realm, this, name); + } + + @Override + public RoleModel addRole(String name) { + return session.realms().addClientRole(realm, this, name); + } + + @Override + public RoleModel addRole(String id, String name) { + return session.realms().addClientRole(realm, this, id, name); + } + + @Override + public boolean removeRole(RoleModel role) { + return session.realms().removeRole(realm, role); + } + + @Override + public Set getRoles() { + return session.realms().getClientRoles(realm, this); + } + + @Override + public Set getRoles(Integer firstResult, Integer maxResults) { + return session.realms().getClientRoles(realm, this, firstResult, maxResults); + } + + @Override + public Set searchForRoles(String search, Integer first, Integer max) { + return session.realms().searchForClientRoles(realm, this, search, first, max); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ClientModel)) return false; + + ClientModel that = (ClientModel) o; + return Objects.equals(that.getId(), getId()); + } + + @Override + public int hashCode() { + return getId().hashCode(); + } +} diff --git a/model/map/src/main/java/org/keycloak/models/map/client/MapClientAdapter.java b/model/map/src/main/java/org/keycloak/models/map/client/MapClientAdapter.java new file mode 100644 index 0000000000..b23aab8be6 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/client/MapClientAdapter.java @@ -0,0 +1,540 @@ +/* + * 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.client; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import com.google.common.base.Functions; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * + * @author hmlnarik + */ +public abstract class MapClientAdapter extends AbstractClientModel implements ClientModel { + + public MapClientAdapter(KeycloakSession session, RealmModel realm, MapClientEntity entity) { + super(session, realm, entity); + } + + @Override + public String getId() { + return entity.getId().toString(); + } + + @Override + public String getClientId() { + return entity.getClientId(); + } + + @Override + public void setClientId(String clientId) { + entity.setClientId(clientId); + } + + @Override + public String getName() { + return entity.getName(); + } + + @Override + public void setName(String name) { + entity.setName(name); + } + + @Override + public String getDescription() { + return entity.getDescription(); + } + + @Override + public void setDescription(String description) { + entity.setDescription(description); + } + + @Override + public boolean isEnabled() { + return entity.isEnabled(); + } + + @Override + public void setEnabled(boolean enabled) { + entity.setEnabled(enabled); + } + + @Override + public boolean isAlwaysDisplayInConsole() { + return entity.isAlwaysDisplayInConsole(); + } + + @Override + public void setAlwaysDisplayInConsole(boolean alwaysDisplayInConsole) { + entity.setAlwaysDisplayInConsole(alwaysDisplayInConsole); + } + + @Override + public boolean isSurrogateAuthRequired() { + return entity.isSurrogateAuthRequired(); + } + + @Override + public void setSurrogateAuthRequired(boolean surrogateAuthRequired) { + entity.setSurrogateAuthRequired(surrogateAuthRequired); + } + + @Override + public Set getWebOrigins() { + return entity.getWebOrigins(); + } + + @Override + public void setWebOrigins(Set webOrigins) { + entity.setWebOrigins(webOrigins); + } + + @Override + public void addWebOrigin(String webOrigin) { + entity.addWebOrigin(webOrigin); + } + + @Override + public void removeWebOrigin(String webOrigin) { + entity.removeWebOrigin(webOrigin); + } + + @Override + public Set getRedirectUris() { + return entity.getRedirectUris(); + } + + @Override + public void setRedirectUris(Set redirectUris) { + entity.setRedirectUris(redirectUris); + } + + @Override + public void addRedirectUri(String redirectUri) { + entity.addRedirectUri(redirectUri); + } + + @Override + public void removeRedirectUri(String redirectUri) { + entity.removeRedirectUri(redirectUri); + } + + @Override + public String getManagementUrl() { + return entity.getManagementUrl(); + } + + @Override + public void setManagementUrl(String url) { + entity.setManagementUrl(url); + } + + @Override + public String getRootUrl() { + return entity.getRootUrl(); + } + + @Override + public void setRootUrl(String url) { + entity.setRootUrl(url); + } + + @Override + public String getBaseUrl() { + return entity.getBaseUrl(); + } + + @Override + public void setBaseUrl(String url) { + entity.setBaseUrl(url); + } + + @Override + public boolean isBearerOnly() { + return entity.isBearerOnly(); + } + + @Override + public void setBearerOnly(boolean only) { + entity.setBearerOnly(only); + } + + @Override + public String getClientAuthenticatorType() { + return entity.getClientAuthenticatorType(); + } + + @Override + public void setClientAuthenticatorType(String clientAuthenticatorType) { + entity.setClientAuthenticatorType(clientAuthenticatorType); + } + + @Override + public boolean validateSecret(String secret) { + return MessageDigest.isEqual(secret.getBytes(), entity.getSecret().getBytes()); + } + + @Override + public String getSecret() { + return entity.getSecret(); + } + + @Override + public void setSecret(String secret) { + entity.setSecret(secret); + } + + @Override + public int getNodeReRegistrationTimeout() { + return entity.getNodeReRegistrationTimeout(); + } + + @Override + public void setNodeReRegistrationTimeout(int timeout) { + entity.setNodeReRegistrationTimeout(timeout); + } + + @Override + public String getRegistrationToken() { + return entity.getRegistrationToken(); + } + + @Override + public void setRegistrationToken(String registrationToken) { + entity.setRegistrationToken(registrationToken); + } + + @Override + public String getProtocol() { + return entity.getProtocol(); + } + + @Override + public void setProtocol(String protocol) { + entity.setProtocol(protocol); + } + + @Override + public void setAttribute(String name, String value) { + entity.setAttribute(name, value); + } + + @Override + public void removeAttribute(String name) { + entity.removeAttribute(name); + } + + @Override + public String getAttribute(String name) { + return entity.getAttribute(name); + } + + @Override + public Map getAttributes() { + return entity.getAttributes(); + } + + @Override + public String getAuthenticationFlowBindingOverride(String binding) { + return entity.getAuthenticationFlowBindingOverride(binding); + } + + @Override + public Map getAuthenticationFlowBindingOverrides() { + return entity.getAuthenticationFlowBindingOverrides(); + } + + @Override + public void removeAuthenticationFlowBindingOverride(String binding) { + entity.removeAuthenticationFlowBindingOverride(binding); + } + + @Override + public void setAuthenticationFlowBindingOverride(String binding, String flowId) { + entity.setAuthenticationFlowBindingOverride(binding, flowId); + } + + @Override + public boolean isFrontchannelLogout() { + return entity.isFrontchannelLogout(); + } + + @Override + public void setFrontchannelLogout(boolean flag) { + entity.setFrontchannelLogout(flag); + } + + @Override + public boolean isFullScopeAllowed() { + return entity.isFullScopeAllowed(); + } + + @Override + public void setFullScopeAllowed(boolean value) { + entity.setFullScopeAllowed(value); + } + + @Override + public boolean isPublicClient() { + return entity.isPublicClient(); + } + + @Override + public void setPublicClient(boolean flag) { + entity.setPublicClient(flag); + } + + @Override + public boolean isConsentRequired() { + return entity.isConsentRequired(); + } + + @Override + public void setConsentRequired(boolean consentRequired) { + entity.setConsentRequired(consentRequired); + } + + @Override + public boolean isStandardFlowEnabled() { + return entity.isStandardFlowEnabled(); + } + + @Override + public void setStandardFlowEnabled(boolean standardFlowEnabled) { + entity.setStandardFlowEnabled(standardFlowEnabled); + } + + @Override + public boolean isImplicitFlowEnabled() { + return entity.isImplicitFlowEnabled(); + } + + @Override + public void setImplicitFlowEnabled(boolean implicitFlowEnabled) { + entity.setImplicitFlowEnabled(implicitFlowEnabled); + } + + @Override + public boolean isDirectAccessGrantsEnabled() { + return entity.isDirectAccessGrantsEnabled(); + } + + @Override + public void setDirectAccessGrantsEnabled(boolean directAccessGrantsEnabled) { + entity.setDirectAccessGrantsEnabled(directAccessGrantsEnabled); + } + + @Override + public boolean isServiceAccountsEnabled() { + return entity.isServiceAccountsEnabled(); + } + + @Override + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + entity.setServiceAccountsEnabled(serviceAccountsEnabled); + } + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public int getNotBefore() { + return entity.getNotBefore(); + } + + @Override + public void setNotBefore(int notBefore) { + entity.setNotBefore(notBefore); + } + + /*************** Client scopes ****************/ + + @Override + public void addClientScope(ClientScopeModel clientScope, boolean defaultScope) { + final String id = clientScope == null ? null : clientScope.getId(); + if (id != null) { + entity.addClientScope(id, defaultScope); + } + } + + @Override + public void removeClientScope(ClientScopeModel clientScope) { + final String id = clientScope == null ? null : clientScope.getId(); + if (id != null) { + entity.removeClientScope(id); + } + } + + @Override + public Map getClientScopes(boolean defaultScope, boolean filterByProtocol) { + Stream res = this.entity.getClientScopes(defaultScope) + .map(realm::getClientScopeById) + .filter(Objects::nonNull); + + if (filterByProtocol) { + String clientProtocol = getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : getProtocol(); + res = res.filter(cs -> Objects.equals(cs.getProtocol(), clientProtocol)); + } + + return res.collect(Collectors.toMap(ClientScopeModel::getName, Functions.identity())); + } + + /*************** Scopes mappings ****************/ + + @Override + public Stream getScopeMappingsStream() { + return this.entity.getScopeMappings().stream() + .map(realm::getRoleById) + .filter(Objects::nonNull); + } + + @Override + public void addScopeMapping(RoleModel role) { + final String id = role == null ? null : role.getId(); + if (id != null) { + this.entity.addScopeMapping(id); + } + } + + @Override + public void deleteScopeMapping(RoleModel role) { + final String id = role == null ? null : role.getId(); + if (id != null) { + this.entity.deleteScopeMapping(id); + } + } + + @Override + public boolean hasScope(RoleModel role) { + if (isFullScopeAllowed()) return true; + + final String id = role == null ? null : role.getId(); + if (id != null && this.entity.getScopeMappings().contains(id)) { + return true; + } + + if (getScopeMappingsStream().anyMatch(r -> r.hasRole(role))) { + return true; + } + + Set roles = getRoles(); + if (roles.contains(role)) return true; + + return roles.stream().anyMatch(r -> r.hasRole(role)); + } + + /*************** Default roles ****************/ + + @Override + public List getDefaultRoles() { + return entity.getDefaultRoles(); + } + + @Override + public void addDefaultRole(String name) { + RoleModel role = getRole(name); + if (role == null) { + addRole(name); + } + this.entity.addDefaultRole(name); + } + + @Override + public void removeDefaultRoles(String... defaultRoles) { + this.entity.removeDefaultRoles(defaultRoles); + } + + /*************** Protocol mappers ****************/ + + @Override + public Set getProtocolMappers() { + return Collections.unmodifiableSet(new HashSet<>(entity.getProtocolMappers())); + } + + @Override + public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) { + if (model == null) { + return null; + } + + ProtocolMapperModel pm = new ProtocolMapperModel(); + pm.setId(KeycloakModelUtils.generateId()); + pm.setName(model.getName()); + pm.setProtocol(model.getProtocol()); + pm.setProtocolMapper(model.getProtocolMapper()); + + if (model.getConfig() != null) { + pm.setConfig(new HashMap<>(model.getConfig())); + } else { + pm.setConfig(new HashMap<>()); + } + + return entity.addProtocolMapper(pm); + } + + @Override + public void removeProtocolMapper(ProtocolMapperModel mapping) { + final String id = mapping == null ? null : mapping.getId(); + if (id != null) { + entity.removeProtocolMapper(id); + } + } + + @Override + public void updateProtocolMapper(ProtocolMapperModel mapping) { + final String id = mapping == null ? null : mapping.getId(); + if (id != null) { + entity.updateProtocolMapper(id, mapping); + } + } + + @Override + public ProtocolMapperModel getProtocolMapperById(String id) { + return entity.getProtocolMapperById(id); + } + + @Override + public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) { + return entity.getProtocolMappers().stream() + .filter(pm -> Objects.equals(pm.getProtocol(), protocol) && Objects.equals(pm.getName(), name)) + .findAny() + .orElse(null); + } +} diff --git a/model/map/src/main/java/org/keycloak/models/map/client/MapClientEntity.java b/model/map/src/main/java/org/keycloak/models/map/client/MapClientEntity.java new file mode 100644 index 0000000000..1a9a7cb095 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/client/MapClientEntity.java @@ -0,0 +1,35 @@ +/* + * 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.client; + +import java.util.UUID; + +/** + * + * @author hmlnarik + */ +public class MapClientEntity extends AbstractClientEntity { + + protected MapClientEntity() { + super(); + } + + public MapClientEntity(UUID id, String realmId) { + super(id, realmId); + } + +} 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 new file mode 100644 index 0000000000..c001553bd8 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProvider.java @@ -0,0 +1,310 @@ +/* + * 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.models.map.client; + +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.RealmModel; + +import org.keycloak.models.RealmModel.ClientUpdatedEvent; +import org.keycloak.models.RealmProvider; +import org.keycloak.models.RoleModel; +import org.keycloak.models.map.storage.MapKeycloakTransaction; +import org.keycloak.models.map.common.Serialization; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.keycloak.models.map.storage.MapStorage; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class MapClientProvider implements ClientProvider { + + protected static final Logger logger = Logger.getLogger(MapClientProvider.class); + private static final Predicate ALWAYS_FALSE = c -> { return false; }; + private final KeycloakSession session; + final MapKeycloakTransaction tx; + private final MapStorage clientStore; + private final ConcurrentMap> clientRegisteredNodesStore; + + private static final Comparator COMPARE_BY_CLIENT_ID = new Comparator() { + @Override + public int compare(MapClientEntity o1, MapClientEntity o2) { + String c1 = o1 == null ? null : o1.getClientId(); + String c2 = o2 == null ? null : o2.getClientId(); + return c1 == c2 ? 0 + : c1 == null ? -1 + : c2 == null ? 1 + : c1.compareTo(c2); + + } + }; + + public MapClientProvider(KeycloakSession session, MapStorage clientStore, ConcurrentMap> clientRegisteredNodesStore) { + this.session = session; + this.clientStore = clientStore; + this.clientRegisteredNodesStore = clientRegisteredNodesStore; + this.tx = new MapKeycloakTransaction<>(clientStore); + session.getTransactionManager().enlistAfterCompletion(tx); + } + + private ClientUpdatedEvent clientUpdatedEvent(ClientModel c) { + return new RealmModel.ClientUpdatedEvent() { + @Override + public ClientModel getUpdatedClient() { + return c; + } + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + }; + } + + private MapClientEntity registerEntityForChanges(MapClientEntity origEntity) { + final MapClientEntity res = Serialization.from(origEntity); + tx.putIfChanged(origEntity.getId(), res, MapClientEntity::isUpdated); + return res; + } + + private Function entityToAdapterFunc(RealmModel realm) { + // Clone entity before returning back, to avoid giving away a reference to the live object to the caller + + return origEntity -> new MapClientAdapter(session, realm, registerEntityForChanges(origEntity)) { + @Override + public void updateClient() { + // commit + MapClientProvider.this.tx.replace(entity.getId(), this.entity); + session.getKeycloakSessionFactory().publish(clientUpdatedEvent(this)); + } + + /** This is runtime information and should have never been part of the adapter */ + @Override + public Map getRegisteredNodes() { + return clientRegisteredNodesStore.computeIfAbsent(entity.getId(), k -> new ConcurrentHashMap<>()); + } + + @Override + public void registerNode(String nodeHost, int registrationTime) { + Map value = getRegisteredNodes(); + value.put(nodeHost, registrationTime); + } + + @Override + public void unregisterNode(String nodeHost) { + getRegisteredNodes().remove(nodeHost); + } + + }; + } + + private Predicate entityRealmFilter(RealmModel realm) { + if (realm == null || realm.getId() == null) { + return MapClientProvider.ALWAYS_FALSE; + } + String realmId = realm.getId(); + return entity -> Objects.equals(realmId, entity.getRealmId()); + } + + @Override + public List getClients(RealmModel realm, Integer firstResult, Integer maxResults) { + Stream s = getClientsStream(realm); + if (firstResult >= 0) { + s = s.skip(firstResult); + } + if (maxResults >= 0) { + s = s.limit(maxResults); + } + return s.collect(Collectors.toList()); + } + + private Stream getNotRemovedUpdatedClientsStream() { + Stream updatedAndNotRemovedClientsStream = clientStore.entrySet().stream() + .map(tx::getUpdated) // If the client has been removed, tx.get will return null, otherwise it will return me.getValue() + .filter(Objects::nonNull); + return Stream.concat(tx.createdValuesStream(clientStore.keySet()), updatedAndNotRemovedClientsStream); + } + +// @Override + public Stream getClientsStream(RealmModel realm) { + return getNotRemovedUpdatedClientsStream() + .filter(entityRealmFilter(realm)) + .sorted(COMPARE_BY_CLIENT_ID) + .map(entityToAdapterFunc(realm)) + ; + } + + @Override + public List getClients(RealmModel realm) { + return getClientsStream(realm).collect(Collectors.toList()); + } + + @Override + public ClientModel addClient(RealmModel realm, String id, String clientId) { + final UUID entityId = id == null ? UUID.randomUUID() : UUID.fromString(id); + + if (clientId == null) { + clientId = entityId.toString(); + } + + MapClientEntity entity = new MapClientEntity(entityId, realm.getId()); + entity.setClientId(clientId); + entity.setEnabled(true); + entity.setStandardFlowEnabled(true); + if (tx.get(entity.getId(), clientStore::get) != null) { + throw new ModelDuplicateException("Client exists: " + id); + } + tx.putIfAbsent(entity.getId(), entity); + final ClientModel resource = entityToAdapterFunc(realm).apply(entity); + + // TODO: Sending an event should be extracted to store layer + session.getKeycloakSessionFactory().publish((RealmModel.ClientCreationEvent) () -> resource); + resource.updateClient(); // This is actualy strange contract - it should be the store code to call updateClient + + return resource; + } + + @Override + public List getAlwaysDisplayInConsoleClients(RealmModel realm) { + return getClientsStream(realm) + .filter(ClientModel::isAlwaysDisplayInConsole) + .collect(Collectors.toList()); + } + + @Override + public void removeClients(RealmModel realm) { + LOG.tracef("removeClients(%s)%s", realm, getShortStackTrace()); + + getClientsStream(realm) + .map(ClientModel::getId) + .collect(Collectors.toSet()) // This is necessary to read out all the client IDs before removing the clients + .forEach(cid -> removeClient(realm, cid)); + } + + @Override + public boolean removeClient(RealmModel realm, String id) { + if (id == null) { + return false; + } + + // TODO: Sending an event (and client role removal) should be extracted to store layer + final ClientModel client = getClientById(realm, id); + if (client == null) return false; + session.users().preRemove(realm, client); + final RealmProvider realms = session.realms(); + for (RoleModel role : client.getRoles()) { + realms.removeRole(realm, role); + } + + session.getKeycloakSessionFactory().publish(new RealmModel.ClientRemovedEvent() { + @Override + public ClientModel getClient() { + return client; + } + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + }); + // TODO: ^^^^^^^ Up to here + + tx.remove(UUID.fromString(id)); + + return true; + } + + @Override + public long getClientsCount(RealmModel realm) { + return this.getNotRemovedUpdatedClientsStream() + .filter(entityRealmFilter(realm)) + .count(); + } + + @Override + public ClientModel getClientById(RealmModel realm, String id) { + if (id == null) { + return null; + } + MapClientEntity entity = tx.get(UUID.fromString(id), clientStore::get); + return (entity == null || ! entityRealmFilter(realm).test(entity)) + ? null + : entityToAdapterFunc(realm).apply(entity); + } + + @Override + public ClientModel getClientByClientId(RealmModel realm, String clientId) { + if (clientId == null) { + return null; + } + String clientIdLower = clientId.toLowerCase(); + + return getNotRemovedUpdatedClientsStream() + .filter(entityRealmFilter(realm)) + .filter(entity -> entity.getClientId() != null && Objects.equals(entity.getClientId().toLowerCase(), clientIdLower)) + .map(entityToAdapterFunc(realm)) + .findFirst() + .orElse(null) + ; + } + + @Override + public List searchClientsByClientId(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) { + if (clientId == null) { + return Collections.EMPTY_LIST; + } + String clientIdLower = clientId.toLowerCase(); + Stream s = getNotRemovedUpdatedClientsStream() + .filter(entityRealmFilter(realm)) + .filter(entity -> entity.getClientId() != null && entity.getClientId().toLowerCase().contains(clientIdLower)) + .sorted(COMPARE_BY_CLIENT_ID); + + if (firstResult >= 0) { + s = s.skip(firstResult); + } + if (maxResults >= 0) { + s = s.limit(maxResults); + } + + return s + .map(entityToAdapterFunc(realm)) + .collect(Collectors.toList()) + ; + } + + @Override + public void close() { + + } + +} diff --git a/model/map/src/main/java/org/keycloak/models/map/client/MapClientProviderFactory.java b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProviderFactory.java new file mode 100644 index 0000000000..1f764c3fef --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/client/MapClientProviderFactory.java @@ -0,0 +1,51 @@ +/* + * 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.client; + +import org.keycloak.models.map.common.AbstractMapProviderFactory; +import org.keycloak.models.ClientProvider; +import org.keycloak.models.ClientProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.keycloak.models.map.storage.MapStorageProvider; +import org.keycloak.models.map.storage.MapStorage; + +/** + * + * @author hmlnarik + */ +public class MapClientProviderFactory extends AbstractMapProviderFactory implements ClientProviderFactory { + + private final ConcurrentHashMap> REGISTERED_NODES_STORE = new ConcurrentHashMap<>(); + + private MapStorage store; + + @Override + public void postInit(KeycloakSessionFactory factory) { + MapStorageProvider sp = (MapStorageProvider) factory.getProviderFactory(MapStorageProvider.class); + this.store = sp.getStorage("clients", UUID.class, MapClientEntity.class); + } + + + @Override + public ClientProvider create(KeycloakSession session) { + return new MapClientProvider(session, store, REGISTERED_NODES_STORE); + } +} diff --git a/model/map/src/main/java/org/keycloak/models/map/common/AbstractEntity.java b/model/map/src/main/java/org/keycloak/models/map/common/AbstractEntity.java new file mode 100644 index 0000000000..e44dcd3cb1 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/common/AbstractEntity.java @@ -0,0 +1,32 @@ +/* + * 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.common; + +/** + * + * @author hmlnarik + */ +public interface AbstractEntity { + + K getId(); + + /** + * Flag signalizing that any of the setters has been meaningfully used. + * @return + */ + boolean isUpdated(); +} diff --git a/model/map/src/main/java/org/keycloak/models/map/common/AbstractMapProviderFactory.java b/model/map/src/main/java/org/keycloak/models/map/common/AbstractMapProviderFactory.java new file mode 100644 index 0000000000..b28d2ee142 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/common/AbstractMapProviderFactory.java @@ -0,0 +1,51 @@ +/* + * 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.common; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.jboss.logging.Logger; + +/** + * + * @author hmlnarik + */ +public abstract class AbstractMapProviderFactory implements ProviderFactory { + + public static final String PROVIDER_ID = "map"; + + protected final Logger LOG = Logger.getLogger(getClass()); + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} 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 new file mode 100644 index 0000000000..ccb1b8000d --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/common/Serialization.java @@ -0,0 +1,61 @@ +/* + * 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.common; + +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +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.SerializationFeature; +import java.io.IOException; + +/** + * + * @author hmlnarik + */ +public class Serialization { + + public static final ObjectMapper MAPPER = new ObjectMapper(); + + abstract class IgnoreUpdatedMixIn { @JsonIgnore public abstract boolean isUpdated(); } + + static { + MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + MAPPER.enable(SerializationFeature.INDENT_OUTPUT); + MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); + MAPPER.setVisibility(PropertyAccessor.ALL, Visibility.NONE); + MAPPER.setVisibility(PropertyAccessor.FIELD, Visibility.ANY); + + MAPPER.addMixIn(AbstractEntity.class, IgnoreUpdatedMixIn.class); + } + + + public static T from(T orig) { + if (orig == null) { + return null; + } + try { + // Naive solution but will do. + final T res = MAPPER.readValue(MAPPER.writeValueAsBytes(orig), (Class) orig.getClass()); + return res; + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + +} 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 new file mode 100644 index 0000000000..21cfa0ad4e --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/ConcurrentHashMapStorageProvider.java @@ -0,0 +1,132 @@ +/* + * 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/MapStorageProvider.java b/model/map/src/main/java/org/keycloak/models/map/storage/MapStorageProvider.java new file mode 100644 index 0000000000..8c7f283803 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/MapStorageProvider.java @@ -0,0 +1,43 @@ +/* + * 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.models.map.common.AbstractEntity; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; + +/** + * + * @author hmlnarik + */ +public interface MapStorageProvider extends Provider, ProviderFactory { + + public enum Flag { + INITIALIZE_EMPTY, + LOCAL + } + + /** + * Returns a key-value storage + * @param type of the primary key + * @param type of the value + * @param name Name of the storage + * @param flags + * @return + */ + > MapStorage getStorage(String name, Class keyType, Class valueType, Flag... flags); +} diff --git a/model/map/src/main/java/org/keycloak/models/map/storage/MapStorageSpi.java b/model/map/src/main/java/org/keycloak/models/map/storage/MapStorageSpi.java new file mode 100644 index 0000000000..bf122bd094 --- /dev/null +++ b/model/map/src/main/java/org/keycloak/models/map/storage/MapStorageSpi.java @@ -0,0 +1,51 @@ +/* + * 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.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * + * @author hmlnarik + */ +public class MapStorageSpi implements Spi { + + public static final String NAME = "mapStorage"; + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Class getProviderClass() { + return MapStorageProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return MapStorageProvider.class; + } + +} diff --git a/model/map/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory b/model/map/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory new file mode 100644 index 0000000000..c83466cb49 --- /dev/null +++ b/model/map/src/main/resources/META-INF/services/org.keycloak.models.ClientProviderFactory @@ -0,0 +1,18 @@ +# +# 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. +# + +org.keycloak.models.map.client.MapClientProviderFactory \ No newline at end of file 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 new file mode 100644 index 0000000000..55ae0f6613 --- /dev/null +++ b/model/map/src/main/resources/META-INF/services/org.keycloak.models.map.storage.MapStorageProvider @@ -0,0 +1,18 @@ +# +# 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. +# + +org.keycloak.models.map.storage.ConcurrentHashMapStorageProvider diff --git a/model/map/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/model/map/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100644 index 0000000000..709abe5853 --- /dev/null +++ b/model/map/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1,18 @@ +# +# 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. +# + +org.keycloak.models.map.storage.MapStorageSpi diff --git a/server-spi-private/src/main/java/org/keycloak/models/ClientProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/ClientProviderFactory.java new file mode 100644 index 0000000000..3117fd080c --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/ClientProviderFactory.java @@ -0,0 +1,23 @@ +/* + * 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; + +import org.keycloak.provider.ProviderFactory; + +public interface ClientProviderFactory extends ProviderFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/ClientSpi.java b/server-spi-private/src/main/java/org/keycloak/models/ClientSpi.java new file mode 100644 index 0000000000..386462c2d8 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/ClientSpi.java @@ -0,0 +1,46 @@ +/* + * 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; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class ClientSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "client"; + } + + @Override + public Class getProviderClass() { + return ClientProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return ClientProviderFactory.class; + } + +} diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 8a34cb8326..57a32f20ce 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -18,6 +18,7 @@ org.keycloak.provider.ExceptionConverterSpi org.keycloak.storage.UserStorageProviderSpi org.keycloak.storage.federated.UserFederatedStorageProviderSpi +org.keycloak.models.ClientSpi org.keycloak.models.RealmSpi org.keycloak.models.ActionTokenStoreSpi org.keycloak.models.CodeToTokenStoreSpi diff --git a/server-spi/src/main/java/org/keycloak/models/ClientModel.java b/server-spi/src/main/java/org/keycloak/models/ClientModel.java index 801b8abba0..0b7b802b77 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientModel.java @@ -35,6 +35,11 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot String PUBLIC_KEY = "publicKey"; String X509CERTIFICATE = "X509Certificate"; + /** + * Stores the current state of the client immediately to the underlying store, similarly to a commit. + * + * @deprecated Do not use, to be removed + */ void updateClient(); /** diff --git a/server-spi/src/main/java/org/keycloak/models/RoleContainerModel.java b/server-spi/src/main/java/org/keycloak/models/RoleContainerModel.java index 21b11374eb..f335d23552 100755 --- a/server-spi/src/main/java/org/keycloak/models/RoleContainerModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RoleContainerModel.java @@ -19,6 +19,10 @@ package org.keycloak.models; import org.keycloak.provider.ProviderEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -53,7 +57,26 @@ public interface RoleContainerModel { void addDefaultRole(String name); - void updateDefaultRoles(String... defaultRoles); + default void updateDefaultRoles(String... defaultRoles) { + List defaultRolesArray = Arrays.asList(defaultRoles); + Collection entities = getDefaultRoles(); + Set already = new HashSet<>(); + ArrayList remove = new ArrayList<>(); + for (String rel : entities) { + if (! defaultRolesArray.contains(rel)) { + remove.add(rel); + } else { + already.add(rel); + } + } + removeDefaultRoles(remove.toArray(new String[] {})); + + for (String roleName : defaultRoles) { + if (!already.contains(roleName)) { + addDefaultRole(roleName); + } + } + } void removeDefaultRoles(String... defaultRoles); diff --git a/server-spi/src/main/java/org/keycloak/models/ScopeContainerModel.java b/server-spi/src/main/java/org/keycloak/models/ScopeContainerModel.java index 201c15ffe0..907ffe6393 100755 --- a/server-spi/src/main/java/org/keycloak/models/ScopeContainerModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ScopeContainerModel.java @@ -18,6 +18,8 @@ package org.keycloak.models; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * @author Bill Burke @@ -25,14 +27,26 @@ import java.util.Set; */ public interface ScopeContainerModel { - Set getScopeMappings(); + @Deprecated + default Set getScopeMappings() { + return getScopeMappingsStream().collect(Collectors.toSet()); + } + + default Stream getScopeMappingsStream() { + return getScopeMappings().stream(); + }; + + /** + * From the scope mappings returned by {@link #getScopeMappings()} returns only those + * that belong to the realm that owns this scope container. + * @return + */ + Set getRealmScopeMappings(); void addScopeMapping(RoleModel role); void deleteScopeMapping(RoleModel role); - Set getRealmScopeMappings(); - boolean hasScope(RoleModel role); } diff --git a/testsuite/integration-arquillian/tests/base/pom.xml b/testsuite/integration-arquillian/tests/base/pom.xml index 4a59ab321a..332b54a312 100644 --- a/testsuite/integration-arquillian/tests/base/pom.xml +++ b/testsuite/integration-arquillian/tests/base/pom.xml @@ -100,6 +100,10 @@ com.google.guava guava + + org.keycloak + keycloak-model-map + org.keycloak.testsuite integration-arquillian-servers-app-server-spi @@ -159,7 +163,27 @@ - + + + org.apache.maven.plugins + maven-antrun-plugin + + + process-test-resources + + run + + + + + + + + + + + + maven-surefire-plugin diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java index 67e6672d2a..4e24092e47 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java @@ -71,6 +71,7 @@ import java.util.*; import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -317,12 +318,12 @@ public class RealmTest extends AbstractAdminTest { realm1 = adminClient.realms().realm("test-immutable").toRepresentation(); realm1.setRealm("test-immutable-old"); adminClient.realms().realm("test-immutable").update(realm1); - realm1 = adminClient.realms().realm("test-immutable-old").toRepresentation(); + assertThat(adminClient.realms().realm("test-immutable-old").toRepresentation(), notNullValue()); RealmRepresentation realm2 = new RealmRepresentation(); realm2.setRealm("test-immutable"); adminClient.realms().create(realm2); - realm2 = adminClient.realms().realm("test-immutable").toRepresentation(); + assertThat(adminClient.realms().realm("test-immutable").toRepresentation(), notNullValue()); adminClient.realms().realm("test-immutable-old").remove(); adminClient.realms().realm("test-immutable").remove(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json index 34226c45c3..8d9845a670 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json @@ -44,6 +44,17 @@ "provider": "${keycloak.user.provider:jpa}" }, + "client": { + "provider": "${keycloak.client.provider:jpa}" + }, + + "mapStorage": { + "provider": "${keycloak.mapStorage.provider:concurrenthashmap}", + "concurrenthashmap": { + "dir": "${project.build.directory:target}" + } + }, + "userFederatedStorage": { "provider": "${keycloak.userFederatedStorage.provider:jpa}" }, diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index 91e50672cb..d51f0a715b 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -18,6 +18,17 @@ "provider": "${keycloak.realm.provider:}" }, + "client": { + "provider": "${keycloak.client.provider:jpa}" + }, + + "mapStorage": { + "provider": "${keycloak.mapStorage.provider:concurrenthashmap}", + "concurrenthashmap": { + "dir": "${project.build.directory:target}" + } + }, + "user": { "provider": "${keycloak.user.provider:}" },