Provide a cache layer for the organization model

Closes #30087

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik 2024-06-10 15:32:48 +02:00 committed by Pedro Igor
parent 08ead04c43
commit d355e38424
11 changed files with 593 additions and 8 deletions

View file

@ -172,6 +172,29 @@ public class RealmCacheSession implements CacheRealmProvider {
return groupDelegate;
}
public Set<String> getInvalidations() {
return Collections.unmodifiableSet(invalidations);
}
public RealmCacheManager getCache() {
return cache;
}
public long getStartupRevision() {
return startupRevision;
}
@Override
public void registerInvalidation(String id) {
invalidations.add(id);
invalidationEvents.add(new InvalidationEvent() {
@Override
public String getId() {
return id;
}
});
}
@Override
public void registerRealmInvalidation(String id, String name) {
cache.realmUpdated(id, name, invalidations);

View file

@ -0,0 +1,87 @@
/*
* Copyright 2024 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.cache.infinispan.organization;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.OrganizationDomainModel;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.infinispan.DefaultLazyLoader;
import org.keycloak.models.cache.infinispan.LazyLoader;
import org.keycloak.models.cache.infinispan.entities.AbstractRevisioned;
import org.keycloak.models.cache.infinispan.entities.InRealm;
public class CachedOrganization extends AbstractRevisioned implements InRealm {
private final RealmModel realm;
private final String name;
private final String description;
private final boolean enabled;
private final LazyLoader<OrganizationModel, MultivaluedHashMap<String, String>> attributes;
private final Set<OrganizationDomainModel> domains;
private final Set<IdentityProviderModel> idps;
public CachedOrganization(Long revision, RealmModel realm, OrganizationModel organization) {
super(revision, organization.getId());
this.realm = realm;
this.name = organization.getName();
this.description = organization.getDescription();
this.enabled = organization.isEnabled();
this.attributes = new DefaultLazyLoader<>(orgModel -> new MultivaluedHashMap<>(orgModel.getAttributes()), MultivaluedHashMap::new);
this.domains = organization.getDomains().collect(Collectors.toSet());
this.idps = organization.getIdentityProviders().collect(Collectors.toSet());
}
@Override
public String getRealm() {
return realm.getId();
}
public RealmModel getRealmModel() {
return realm;
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
public boolean isEnabled() {
return enabled;
}
public MultivaluedHashMap<String, String> getAttributes(Supplier<OrganizationModel> organizationModel) {
return attributes.get(organizationModel);
}
public Stream<OrganizationDomainModel> getDomains() {
return domains.stream();
}
public Stream<IdentityProviderModel> getIdentityProviders() {
return idps.stream();
}
}

View file

@ -0,0 +1,222 @@
/*
* Copyright 2024 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.cache.infinispan.organization;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.CacheRealmProvider;
import org.keycloak.models.cache.infinispan.RealmCacheSession;
import org.keycloak.organization.OrganizationProvider;
public class InfinispanOrganizationProvider implements OrganizationProvider {
private final KeycloakSession session;
private final OrganizationProvider jpaOrgDelegate;
private final RealmCacheSession realmCache;
private final Map<String, OrganizationAdapter> managedOrganizations = new HashMap<>();
public InfinispanOrganizationProvider(KeycloakSession session) {
this.session = session;
this.jpaOrgDelegate = session.getProvider(OrganizationProvider.class, "jpa");
this.realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);
}
@Override
public OrganizationModel create(String name) {
return jpaOrgDelegate.create(name);
}
@Override
public OrganizationModel getById(String id) {
CachedOrganization cached = realmCache.getCache().get(id, CachedOrganization.class);
String realmId = getRealm().getId();
if (cached != null && !cached.getRealm().equals(realmId)) {
cached = null;
}
if (cached == null) {
Long loaded = realmCache.getCache().getCurrentRevision(id);
OrganizationModel model = jpaOrgDelegate.getById(id);
if (model == null) return null;
if (realmCache.getInvalidations().contains(id)) return model;
cached = new CachedOrganization(loaded, getRealm(), model);
realmCache.getCache().addRevisioned(cached, realmCache.getStartupRevision());
// no need to check for realm invalidation as IdP changes are handled by events within InfinispanOrganizationProviderFactory
} else if (realmCache.getInvalidations().contains(id)) {
return orgDelegate.getById(id);
} else if (managedOrganizations.containsKey(id)) {
return managedOrganizations.get(id);
}
OrganizationAdapter adapter = new OrganizationAdapter(cached, realmCache, jpaOrgDelegate);
managedOrganizations.put(id, adapter);
return adapter;
}
@Override
public OrganizationModel getByDomainName(String domainName) {
String cacheKey = getRealm().getId() + "+.org.domain.name." + domainName;
CachedOrganization cached = realmCache.getCache().get(cacheKey, CachedOrganization.class);
String realmId = getRealm().getId();
if (cached != null && !cached.getRealm().equals(realmId)) {
cached = null;
}
if (cached == null) {
Long loaded = realmCache.getCache().getCurrentRevision(cacheKey);
OrganizationModel model = jpaOrgDelegate.getByDomainName(domainName);
if (model == null) return null;
if (realmCache.getInvalidations().contains(model.getId())) return model;
cached = new CachedOrganization(loaded, getRealm(), model);
realmCache.getCache().addRevisioned(cached, realmCache.getStartupRevision());
// no need to check for realm invalidation as IdP changes are handled by events within InfinispanOrganizationProviderFactory
} else if (realmCache.getInvalidations().contains(cached.getId())) {
return orgDelegate.getByDomainName(domainName);
} else if (managedOrganizations.containsKey(cached.getId())) {
return managedOrganizations.get(cached.getId());
}
OrganizationAdapter adapter = new OrganizationAdapter(cached, realmCache, jpaOrgDelegate);
managedOrganizations.put(cacheKey, adapter);
return adapter;
}
@Override
public Stream<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max) {
// Return cache delegates to ensure cache invalidation during write operations
return getCacheDelegates(jpaOrgDelegate.getAllStream(search, exact, first, max));
}
@Override
public Stream<OrganizationModel> getAllStream(Map<String, String> attributes, Integer first, Integer max) {
// Return cache delegates to ensure cache invalidation during write operations
return getCacheDelegates(jpaOrgDelegate.getAllStream(attributes, first, max));
}
@Override
public boolean remove(OrganizationModel organization) {
registerOrganizationInvalidation(organization.getId());
return jpaOrgDelegate.remove(organization);
}
@Override
public void removeAll() {
//TODO: won't scale, requires a better mechanism for bulk deleting organizations within a realm
//this way, all organizations in the realm will be invalidated ... or should it be invalidated whole realm instead?
getAllStream().forEach(this::remove);
}
@Override
public boolean addMember(OrganizationModel organization, UserModel user) {
return jpaOrgDelegate.addMember(organization, user);
}
@Override
public boolean removeMember(OrganizationModel organization, UserModel member) {
return jpaOrgDelegate.removeMember(organization, member);
}
@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
return jpaOrgDelegate.getMembersStream(organization, search, exact, first, max);
}
@Override
public UserModel getMemberById(OrganizationModel organization, String id) {
return jpaOrgDelegate.getMemberById(organization, id);
}
@Override
public OrganizationModel getByMember(UserModel member) {
// Return cache delegate to ensure cache invalidation during write operations
return getCacheDelegate(jpaOrgDelegate.getByMember(member));
}
@Override
public boolean isManagedMember(OrganizationModel organization, UserModel member) {
return jpaOrgDelegate.isManagedMember(organization, member);
}
@Override
public boolean addIdentityProvider(OrganizationModel organization, IdentityProviderModel identityProvider) {
boolean added = jpaOrgDelegate.addIdentityProvider(organization, identityProvider);
if (added) {
registerOrganizationInvalidation(organization.getId());
}
return added;
}
@Override
public Stream<IdentityProviderModel> getIdentityProviders(OrganizationModel organization) {
return jpaOrgDelegate.getIdentityProviders(organization);
}
@Override
public boolean removeIdentityProvider(OrganizationModel organization, IdentityProviderModel identityProvider) {
boolean removed = jpaOrgDelegate.removeIdentityProvider(organization, identityProvider);
if (removed) {
registerOrganizationInvalidation(organization.getId());
}
return removed;
}
@Override
public boolean isEnabled() {
return getRealm().isOrganizationsEnabled();
}
@Override
public long count() {
return jpaOrgDelegate.count();
}
@Override
public void close() {
jpaOrgDelegate.close();
}
void registerOrganizationInvalidation(String orgId) {
OrganizationAdapter adapter = managedOrganizations.get(orgId);
if (adapter != null) {
adapter.invalidate();
}
realmCache.registerInvalidation(orgId);
}
private RealmModel getRealm() {
RealmModel realm = session.getContext().getRealm();
if (realm == null) {
throw new IllegalArgumentException("Session not bound to a realm");
}
return realm;
}
private OrganizationModel getCacheDelegate(OrganizationModel model) {
return model == null ? null : getById(model.getId());
}
private Stream<OrganizationModel> getCacheDelegates(Stream<OrganizationModel> backendOrganizations) {
return backendOrganizations.map(this::getCacheDelegate);
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2024 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.cache.infinispan.organization;
import org.keycloak.Config.Scope;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.models.OrganizationModel;
import org.keycloak.organization.OrganizationProviderFactory;
public class InfinispanOrganizationProviderFactory implements OrganizationProviderFactory {
public static final String PROVIDER_ID = "infinispan";
@Override
public OrganizationProvider create(KeycloakSession session) {
return new InfinispanOrganizationProvider(session);
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
factory.register(event -> {
if (event instanceof RealmModel.IdentityProviderUpdatedEvent idpUpdatedEvent) {
registerOrganizationInvalidation(idpUpdatedEvent.getKeycloakSession(), idpUpdatedEvent.getUpdatedIdentityProvider());
}
if (event instanceof RealmModel.IdentityProviderRemovedEvent idpRemovedEvent) {
registerOrganizationInvalidation(idpRemovedEvent.getKeycloakSession(), idpRemovedEvent.getRemovedIdentityProvider());
}
});
}
private void registerOrganizationInvalidation(KeycloakSession session, IdentityProviderModel idp) {
if (idp.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE) != null) {
InfinispanOrganizationProvider orgProvider = (InfinispanOrganizationProvider) session.getProvider(OrganizationProvider.class, getId());
if (orgProvider != null) {
orgProvider.registerOrganizationInvalidation(idp.getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE));
}
}
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public int order() {
return 10;
}
}

View file

@ -0,0 +1,148 @@
/*
* Copyright 2024 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.cache.infinispan.organization;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.OrganizationDomainModel;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.CacheRealmProvider;
import org.keycloak.organization.OrganizationProvider;
public class OrganizationAdapter implements OrganizationModel {
private volatile boolean invalidated;
private volatile OrganizationModel updated;
private final Supplier<OrganizationModel> modelSupplier;
private final CacheRealmProvider realmCache;
private final CachedOrganization cached;
private final OrganizationProvider delegate;
public OrganizationAdapter(CachedOrganization cached, CacheRealmProvider realmCache, OrganizationProvider delegate) {
this.cached = cached;
this.realmCache = realmCache;
this.delegate = delegate;
this.modelSupplier = this::getOrganizationModel;
}
void invalidate() {
invalidated = true;
}
private OrganizationModel getOrganizationModel() {
return delegate.getById(cached.getId());
}
private boolean isUpdated() {
if (updated != null) return true;
if (!invalidated) return false;
updated = getOrganizationModel();
if (updated == null) throw new IllegalStateException("Not found in database");
return true;
}
private void getDelegateForUpdate() {
if (updated == null) {
realmCache.registerInvalidation(cached.getId());
updated = modelSupplier.get();
if (updated == null) throw new IllegalStateException("Not found in database");
}
}
@Override
public String getId() {
if (isUpdated()) return updated.getId();
return cached.getId();
}
@Override
public String getName() {
if (isUpdated()) return updated.getName() ;
return cached.getName();
}
@Override
public void setName(String name) {
getDelegateForUpdate();
updated.setName(name);
}
@Override
public boolean isEnabled() {
if (isUpdated()) return updated.isEnabled();
return cached.isEnabled();
}
@Override
public void setEnabled(boolean enabled) {
getDelegateForUpdate();
updated.setEnabled(enabled);
}
@Override
public String getDescription() {
if (isUpdated()) return updated.getDescription();
return cached.getDescription();
}
@Override
public void setDescription(String description) {
getDelegateForUpdate();
updated.setDescription(description);
}
@Override
public Map<String, List<String>> getAttributes() {
if (isUpdated()) return updated.getAttributes();
return cached.getAttributes(modelSupplier);
}
@Override
public void setAttributes(Map<String, List<String>> attributes) {
getDelegateForUpdate();
updated.setAttributes(attributes);
}
@Override
public Stream<OrganizationDomainModel> getDomains() {
if (isUpdated()) return updated.getDomains();
return cached.getDomains();
}
@Override
public void setDomains(Set<OrganizationDomainModel> domains) {
getDelegateForUpdate();
updated.setDomains(domains);
}
@Override
public Stream<IdentityProviderModel> getIdentityProviders() {
if (isUpdated()) return updated.getIdentityProviders();
return cached.getIdentityProviders();
}
@Override
public boolean isManaged(UserModel user) {
return delegate.isManagedMember(this, user);
}
}

View file

@ -0,0 +1,18 @@
#
# Copyright 2024 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.cache.infinispan.organization.InfinispanOrganizationProviderFactory

View file

@ -39,4 +39,5 @@ public interface CacheRealmProvider extends RealmProvider, ClientProvider, Clien
void registerRoleInvalidation(String id, String roleName, String roleContainerId);
void registerGroupInvalidation(String id);
void registerInvalidation(String id);
}

View file

@ -147,12 +147,12 @@ public interface OrganizationProvider extends Provider {
/**
* @param organization the organization
* @return The identityProvider associated with a given {@code organization} or {@code null} if there is none.
* @return Stream of the identity providers associated with the given {@code organization}. Never returns {@code null}.
*/
Stream<IdentityProviderModel> getIdentityProviders(OrganizationModel organization);
/**
* Removes the link between the given {@link OrganizationModel} and identity provider associated with it if such a link exists.
* Removes the link between the given {@link OrganizationModel} and the identity provider associated with it if such a link exists.
*
* @param organization the organization
* @param identityProvider the identity provider

View file

@ -17,11 +17,8 @@
package org.keycloak.organization.admin.resource;
import static java.util.Optional.ofNullable;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jakarta.ws.rs.Consumes;
@ -45,7 +42,6 @@ import org.keycloak.models.ModelValidationException;
import org.keycloak.models.OrganizationModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;

View file

@ -17,6 +17,8 @@
package org.keycloak.testsuite.organization.broker;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@ -45,6 +47,7 @@ import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.util.UserBuilder;
public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganizationTest {
@ -783,6 +786,8 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
log.debug("Updating info on updateAccount page");
updateAccountInformationPage.updateAccountInformation(user.getUsername(), user.getEmail(), "Firstname", "Lastname");
assertThat(appPage.getRequestType(),is(AppPage.RequestType.AUTH_RESPONSE));
UserRepresentation account = getUserRepresentation(user.getEmail());
realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout();
realmsResouce().realm(bc.providerRealmName()).logoutAll();

View file

@ -111,11 +111,20 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest {
IdentityProviderRepresentation idpTemplate = organization
.identityProviders().get(bc.getIDPAlias()).toRepresentation();
//remove Org related stuff from the template
idpTemplate.getConfig().remove(OrganizationModel.ORGANIZATION_ATTRIBUTE);
idpTemplate.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE);
idpTemplate.getConfig().remove(OrganizationModel.IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
for (int i = 0; i < 5; i++) {
idpTemplate.setAlias("idp-" + i);
idpTemplate.setInternalId(null);
testRealm().identityProviders().create(idpTemplate).close();
organization.identityProviders().addIdentityProvider(idpTemplate.getAlias()).close();
try (Response response = testRealm().identityProviders().create(idpTemplate)) {
assertThat("Falied to create idp-" + i, response.getStatus(), equalTo(Status.CREATED.getStatusCode()));
}
try (Response response = organization.identityProviders().addIdentityProvider(idpTemplate.getAlias())) {
assertThat("Falied to add idp-" + i, response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode()));
}
}
Assert.assertEquals(6, organization.identityProviders().getIdentityProviders().size());