/** * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file * distributed with this work for additional information regarding copyright ownership. Apereo * licenses this file to you 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 the * following location: * * <p>http://www.apache.org/licenses/LICENSE-2.0 * * <p>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.apereo.portal.groups; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Set; import javax.naming.Name; import net.sf.ehcache.Cache; import net.sf.ehcache.CacheManager; import net.sf.ehcache.Element; import org.apereo.portal.EntityIdentifier; import org.apereo.portal.IBasicEntity; import org.apereo.portal.spring.locator.ApplicationContextLocator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; /** * Reference implementation for <code>IEntityGroup</code> * * <p>Groups do not keep references to their members but instead cache member keys. The members are * cached externally. The rules for controlling access to the key caches are a bit obscure, but you * should understand them before writing code that updates groups. Access to the caches themselves * is synchronized via the cache getters and setters. All requests to get group members and to add * or remove group members ultimately go through these methods. The mutating methods, <code> * addChild()</code> and <code>removeChild()</code> however, do a copy-on-write. That is, they first * make a copy of the cache, add or remove the member key, and then replace the original cache with * the copy. This permits multiple read and write threads to run concurrently without throwing * <code>ConcurrentModificationExceptions</code>. But it still leaves open the danger of data races * because nothing in this class guarantees serialized write access. You must impose this from * without, either via explicit locking (<code>GroupService.getLockableGroup()</code>) or by * synchronizing access from the caller. * * @see IEntityGroup */ public class EntityGroupImpl extends GroupMemberImpl implements IEntityGroup { private String creatorID; private String name; private String description; private IIndividualGroupService localGroupService; private final Logger logger = LoggerFactory.getLogger(getClass()); private final Cache childrenCache; // A group and its members share an entityType. private Class<? extends IBasicEntity> leafEntityType; /* * References to updated group members. These updates do not become visible to * the members until the update is committed. */ private HashMap<String, IGroupMember> addedMembers; private HashMap<String, IGroupMember> removedMembers; /** EntityGroupImpl */ public EntityGroupImpl(String groupKey, Class<? extends IBasicEntity> entityType) throws GroupsException { super(new CompositeEntityIdentifier(groupKey, ICompositeGroupService.GROUP_ENTITY_TYPE)); if (isKnownEntityType(entityType)) { leafEntityType = entityType; } else { throw new GroupsException("Unknown entity type: " + entityType); } ApplicationContext context = ApplicationContextLocator.getApplicationContext(); CacheManager cacheManager = context.getBean("cacheManager", CacheManager.class); this.childrenCache = cacheManager.getCache("org.apereo.portal.groups.EntityGroupImpl.children"); } /** * Adds <code>IGroupMember</code> gm to our member <code>Map</code> and conversely, adds <code> * this</code> to gm's group <code>Map</code>, after checking that the addition does not violate * group rules. Remember that we have added it so we can update the database if necessary. * * @param gm org.apereo.portal.groups.IGroupMember */ @Override public void addChild(IGroupMember gm) throws GroupsException { try { checkProspectiveMember(gm); } catch (GroupsException ge) { throw new GroupsException("Could not add IGroupMember", ge); } if (!this.contains(gm)) { String cacheKey = gm.getEntityIdentifier().getKey(); if (getRemovedMembers().containsKey(cacheKey)) { getRemovedMembers().remove(cacheKey); } else { getAddedMembers().put(cacheKey, gm); } } primAddMember(gm); } /** * A member must share the <code>entityType</code> of its containing <code>IEntityGroup</code>. * If it is a group, it must have a unique name within each of its containing groups and the * resulting group must not contain a circular reference. Removed the requirement for unique * group names. (03-04-2004, de) * * @param gm org.apereo.portal.groups.IGroupMember * @exception GroupsException */ private void checkProspectiveMember(IGroupMember gm) throws GroupsException { if (gm.equals(this)) { throw new GroupsException("Attempt to add " + gm + " to itself."); } // Type check: if (this.getLeafType() != gm.getLeafType()) { throw new GroupsException(this + " and " + gm + " have different entity types."); } // Circular reference check: if (gm.isGroup() && gm.asGroup().deepContains(this)) { throw new GroupsException( "Adding " + gm + " to " + this + " creates a circular reference."); } } /** Clear out caches for pending adds and deletes of group members. */ protected void clearPendingUpdates() { addedMembers = null; removedMembers = null; } /** * Checks if <code>GroupMember</code> gm is a member of this. * * @return boolean * @param gm org.apereo.portal.groups.IGroupMember */ @Override public boolean contains(IGroupMember gm) throws GroupsException { return getChildren().contains(gm); } private synchronized Set<IGroupMember> buildChildrenSet() throws GroupsException { logger.debug("Constructing children for group='{}'", getUnderlyingEntityIdentifier()); final Set<IGroupMember> rslt = new HashSet<>(); for (Iterator it = getLocalGroupService().findMembers(this); it.hasNext(); ) { final IGroupMember member = (IGroupMember) it.next(); rslt.add(member); } return Collections.unmodifiableSet(rslt); } /** * Checks recursively if <code>GroupMember</code> gm is a member of this. * * @return boolean * @param gm org.apereo.portal.groups.IGroupMember */ @Override public boolean deepContains(IGroupMember gm) throws GroupsException { if (this.contains(gm)) { return true; } boolean found = false; Iterator<IEntityGroup> it = getMemberGroups(); while (it.hasNext() && !found) { IEntityGroup group = (IEntityGroup) it.next(); if (group != null) { found = group.deepContains(gm); } else { // Something bad has happened: we've abruptly lost a group node to // which this group node refers. This is an ERROR condition, but we // shouldn't simply throw an exception because we know (from // experience) it could make the portal unusable. We definitely, // need, however, to send a strong message. String msg = "Groups Integrety Error: Group '" + this.getName() + "' refers to a child group that is no longer available"; logger.error(msg); } } return found; } /** Delegates to the factory. */ @Override public void delete() throws GroupsException { getLocalGroupService().deleteGroup(this); } /** @return HashMap */ public HashMap<String, IGroupMember> getAddedMembers() { if (this.addedMembers == null) this.addedMembers = new HashMap<>(); return addedMembers; } /** * Returns an <code>Iterator</code> over the <code>Set</code> of recursively-retrieved <code> * IGroupMembers</code> that are members of this <code>IEntityGroup</code>. * * @return Iterator */ @Override public Set<IGroupMember> getDescendants() throws GroupsException { return primGetAllMembers(new HashSet<IGroupMember>()); } /** * Returns the <code>EntityIdentifier</code> cast to a <code>CompositeEntityIdentifier</code> so * that its service nodes can be pushed and popped. * * @return CompositeEntityIdentifier */ private CompositeEntityIdentifier getCompositeEntityIdentifier() { return (CompositeEntityIdentifier) getEntityIdentifier(); } /** @return String */ @Override public String getCreatorID() { return creatorID; } /** @return String */ @Override public String getDescription() { return description; } /** @return EntityIdentifier */ @Override public EntityIdentifier getEntityIdentifier() { return getUnderlyingEntityIdentifier(); } /** * Returns the entity type of this groups's members. * * @return Class * @see org.apereo.portal.EntityTypes */ @Override public Class<? extends IBasicEntity> getLeafType() { return leafEntityType; } /** @return IIndividualGroupService */ protected IIndividualGroupService getLocalGroupService() { return localGroupService; } /** * Returns the key from the group service of origin. * * @return String */ @Override public String getLocalKey() { return getCompositeEntityIdentifier().getLocalKey(); } /** * Returns an <code>Iterator</code> over the groups in our member <code>Collection</code>. * Reflects pending changes. * * @return Iterator */ private Iterator<IEntityGroup> getMemberGroups() throws GroupsException { Set<IEntityGroup> rslt = new HashSet<>(); for (IGroupMember child : getChildren()) { if (child.isGroup()) { rslt.add((IEntityGroup) child); } } return rslt.iterator(); } /** * Returns an <code>Iterator</code> over the <code>GroupMembers</code> in our member <code> * Collection</code>. Reflects pending changes. * * @return Iterator */ @Override public Set<IGroupMember> getChildren() throws GroupsException { final EntityIdentifier cacheKey = getUnderlyingEntityIdentifier(); Element element = childrenCache.get(cacheKey); if (element == null) { final Set<IGroupMember> children = buildChildrenSet(); element = new Element(cacheKey, children); childrenCache.put(element); } @SuppressWarnings("unchecked") final Set<IGroupMember> rslt = (Set<IGroupMember>) element.getObjectValue(); return rslt; } /** @return String */ @Override public String getName() { return name; } /** @return HashMap */ public HashMap<String, IGroupMember> getRemovedMembers() { if (this.removedMembers == null) this.removedMembers = new HashMap<>(); return removedMembers; } /** * Returns the Name of the group service of origin. * * @return javax.naming.Nme */ @Override public Name getServiceName() { return getCompositeEntityIdentifier().getServiceName(); } /** * Returns this object's type for purposes of caching and locking, as opposed to the underlying * entity type. * * @return Class */ @Override public Class<?> getType() { return ICompositeGroupService.GROUP_ENTITY_TYPE; } /** * Answers if there are any added memberships not yet committed to the database. * * @return boolean */ /* package-private */ boolean hasAdds() { return (addedMembers != null) && (addedMembers.size() > 0); } /** * Answers if there are any deleted memberships not yet committed to the database. * * @return boolean */ /* package-private */ boolean hasDeletes() { return (removedMembers != null) && (removedMembers.size() > 0); } /** @return boolean */ @Override public boolean hasMembers() throws GroupsException { return !getChildren().isEmpty(); } /** * Answers if there are any added or deleted memberships not yet committed to the database. * * @return boolean */ public boolean isDirty() { return hasAdds() || hasDeletes(); } /** * Answers if this <code>IEntityGroup</code> can be changed or deleted. * * @return boolean * @exception GroupsException */ @Override public boolean isEditable() throws GroupsException { return getLocalGroupService().isEditable(this); } /** @return boolean */ @Override public boolean isGroup() { return true; } /** * Adds the <code>IGroupMember</code> key to the appropriate member key cache by copying the * cache, adding to the copy, and then replacing the original with the copy. At this point, * <code>gm</code> does not yet have <code>this</code> in its containing group cache. That cache * entry is not added until update(), when changes are committed to the store. * * @param gm org.apereo.portal.groups.IGroupMember */ private void primAddMember(IGroupMember gm) throws GroupsException { final EntityIdentifier cacheKey = getUnderlyingEntityIdentifier(); Element element = childrenCache.get(cacheKey); @SuppressWarnings("unchecked") final Set<IGroupMember> set = element != null ? (Set<IGroupMember>) element.getObjectValue() : buildChildrenSet(); final Set<IGroupMember> children = new HashSet<>(set); children.add(gm); childrenCache.put(new Element(cacheKey, children)); } /** * Returns the <code>Set</code> of <code>IGroupMembers</code> in our member <code>Collection * </code> and, recursively, in the <code>Collections</code> of our members. * * @param rslt Set - a Set that members are added to. * @return Set */ private Set<IGroupMember> primGetAllMembers(Set<IGroupMember> rslt) throws GroupsException { for (IGroupMember gm : getChildren()) { rslt.add(gm); if (gm.isGroup()) { ((EntityGroupImpl) gm).primGetAllMembers(rslt); } } return rslt; } /** * Removes the <code>IGroupMember</code> key from the appropriate key cache, by copying the * cache, removing the key from the copy and replacing the original with the copy. At this * point, <code>gm</code> still has <code>this</code> in its containing groups cache. That cache * entry is not removed until update(), when changes are committed to the store. * * @param gm org.apereo.portal.groups.IGroupMember */ private void primRemoveMember(IGroupMember gm) throws GroupsException { final EntityIdentifier cacheKey = getUnderlyingEntityIdentifier(); Element element = childrenCache.get(cacheKey); @SuppressWarnings("unchecked") final Set<IGroupMember> set = element != null ? (Set<IGroupMember>) element.getObjectValue() : buildChildrenSet(); final Set<IGroupMember> children = new HashSet<>(set); children.remove(gm); childrenCache.put(new Element(cacheKey, children)); } /** @param newName String */ public void primSetName(String newName) { name = newName; } /** * Removes <code>IGroupMember</code> gm from our member <code>Map</code> and, conversely, remove * this from gm's group <code>Map</code>. Remember that we have removed it so we can update the * database, if necessary. * * @param gm org.apereo.portal.groups.IGroupMember */ @Override public void removeChild(IGroupMember gm) throws GroupsException { String cacheKey = gm.getEntityIdentifier().getKey(); if (getAddedMembers().containsKey(cacheKey)) { getAddedMembers().remove(cacheKey); } else { getRemovedMembers().put(cacheKey, gm); } primRemoveMember(gm); } /** @param newCreatorID String */ @Override public void setCreatorID(String newCreatorID) { creatorID = newCreatorID; } /** @param newDescription String */ @Override public void setDescription(String newDescription) { description = newDescription; } /** @param newIndividualGroupService IIndividualGroupService */ @Override public void setLocalGroupService(IIndividualGroupService newIndividualGroupService) throws GroupsException { localGroupService = newIndividualGroupService; setServiceName(localGroupService.getServiceName()); } /** * We used to check duplicate sibling names but no longer do. * * @param newName String */ @Override public void setName(String newName) throws GroupsException { primSetName(newName); } /** Sets the service Name of the group service of origin. */ public void setServiceName(Name newServiceName) throws GroupsException { try { getCompositeEntityIdentifier().setServiceName(newServiceName); } catch (javax.naming.InvalidNameException ine) { throw new GroupsException("Problem setting service name", ine); } } /** * Returns a String that represents the value of this object. * * @return a string representation of the receiver */ @Override public String toString() { return "EntityGroupImpl (" + getKey() + ") " + getName(); } /** Delegate to the factory. */ @Override public void update() throws GroupsException { getLocalGroupService().updateGroup(this); clearPendingUpdates(); } /** Delegate to the factory. */ @Override public void updateMembers() throws GroupsException { // Track objects to invalidate Set<IGroupMember> invalidate = new HashSet<>(); invalidate.addAll(getAddedMembers().values()); invalidate.addAll(getRemovedMembers().values()); getLocalGroupService().updateGroupMembers(this); clearPendingUpdates(); // Invalidate objects that changed their relationship with us this.invalidateInParentGroupsCache(invalidate); } /** Casts to IEntityGroup. */ @Override public IEntityGroup asGroup() { return (IEntityGroup) this; } }