/*
* (C) Copyright 2016 Netcentric AG.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package biz.netcentric.cq.tools.actool.aceinstaller;
import java.security.Principal;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import javax.jcr.PathNotFoundException;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.UnsupportedRepositoryOperationException;
import javax.jcr.security.AccessControlEntry;
import javax.jcr.security.AccessControlManager;
import javax.jcr.security.Privilege;
import org.apache.commons.lang.StringUtils;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Reference;
import org.apache.felix.scr.annotations.Service;
import org.apache.jackrabbit.api.security.JackrabbitAccessControlList;
import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
import org.apache.sling.jcr.api.SlingRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.day.cq.security.util.CqActions;
import biz.netcentric.cq.tools.actool.configmodel.AceBean;
import biz.netcentric.cq.tools.actool.configmodel.Restriction;
import biz.netcentric.cq.tools.actool.helper.AcHelper;
import biz.netcentric.cq.tools.actool.helper.AccessControlUtils;
import biz.netcentric.cq.tools.actool.helper.Constants;
import biz.netcentric.cq.tools.actool.history.AcInstallationLog;
@Service
@Component
public class AceBeanInstallerIncremental extends BaseAceBeanInstaller implements AceBeanInstaller {
@Reference
private SlingRepository slingRepository;
private static final Logger LOG = LoggerFactory.getLogger(AceBeanInstallerIncremental.class);
private Map<String, Set<AceBean>> actionsToPrivilegesMapping = new ConcurrentHashMap<String, Set<AceBean>>();
/** Installs a full set of ACE beans that form an ACL for the path
*
* @throws RepositoryException */
protected void installAcl(Set<AceBean> aceBeanSetFromConfig, String path, Set<String> principalsInConfiguration, Session session,
AcInstallationLog installLog) throws RepositoryException {
boolean hadPendingChanges = session.hasPendingChanges();
int countDeleted = 0;
int countAdded = 0;
int countNoChange = 0;
int countOutsideConfig = 0;
StringBuilder diffLog = new StringBuilder();
aceBeanSetFromConfig = transformActionsIntoPrivileges(aceBeanSetFromConfig, session, installLog);
aceBeanSetFromConfig = filterInitialContentOnlyNodes(aceBeanSetFromConfig);
aceBeanSetFromConfig = filterDuplicates(aceBeanSetFromConfig, session);
List<AceBean> configuredAceEntries = new ArrayList<AceBean>(aceBeanSetFromConfig);
int currentPositionConfig = 0;
boolean changeHasBeenFound = false;
AccessControlManager acMgr = session.getAccessControlManager();
JackrabbitAccessControlList acl = getAccessControlList(acMgr, path);
Iterator<AccessControlEntry> aceIt = Arrays.asList(acl.getAccessControlEntries()).iterator();
while (aceIt.hasNext()) {
AccessControlEntry ace = aceIt.next();
AceBean actualAceBean = AcHelper.getAceBean(ace, acl);
String acePrincipalName = actualAceBean.getPrincipalName();
String actualAceBeanCompareStr = toAceCompareString(actualAceBean, acMgr);
if (!principalsInConfiguration.contains(acePrincipalName)) {
countOutsideConfig++;
diffLog.append(" OUTSIDE (not in Config) " + actualAceBeanCompareStr + "\n");
continue;
}
AceBean configuredAceAtThisLocation;
if (currentPositionConfig < configuredAceEntries.size()) {
configuredAceAtThisLocation = configuredAceEntries.get(currentPositionConfig);
} else {
// LOG.info("There are now fewer ACEs configured at path " + path + " than there was before");
changeHasBeenFound = true;
configuredAceAtThisLocation = null; // setting explicitly to null
}
String configuredAceAtThisLocationCompareStr = toAceCompareString(configuredAceAtThisLocation, acMgr);
boolean dumpEqualToConfig = StringUtils.equals(actualAceBeanCompareStr, configuredAceAtThisLocationCompareStr);
if (!changeHasBeenFound && !dumpEqualToConfig) {
String configBeanStr = configuredAceAtThisLocationCompareStr;
diffLog.append("<<< CHANGE (Repo Version) " + actualAceBeanCompareStr
+ "\n>>> CHANGE (Config Version) " + configBeanStr + "\n");
}
if (changeHasBeenFound || !dumpEqualToConfig) {
changeHasBeenFound = true; // first difference means we delete the rest of the acl and recreate it in the following loop
acl.removeAccessControlEntry(ace);
countDeleted++;
diffLog.append(" DELETED (from Repo) " + actualAceBeanCompareStr + "\n");
continue; // we do not touch currentPositionConfig anymore, we'll have to recreate from there
}
currentPositionConfig++; // found equal ACE, compare next pair
countNoChange++;
diffLog.append(" UNCHANGED " + actualAceBeanCompareStr + "\n");
}
// install missing - this can be either because not all configured ACEs were found (append) or because a change was detected and old
// aces have been deleted
for (int i = currentPositionConfig; i < configuredAceEntries.size(); i++) {
AceBean aceBeanToAppend = configuredAceEntries.get(i);
// LOG.info("installing aceBeanToAppend=" + aceBeanToAppend);
installPrivileges(aceBeanToAppend, new PrincipalImpl(aceBeanToAppend.getPrincipalName()), acl, session, acMgr);
diffLog.append(" APPENDED (from Config) " + toAceCompareString(aceBeanToAppend, acMgr) + "\n");
countAdded++;
}
if (countAdded > 0 || countDeleted > 0) {
acMgr.setPolicy(StringUtils.isNotBlank(path) ? path : /* repo level permission */null, acl);
installLog.incCountAclsChanged();
installLog.addVerboseMessage(LOG, "Update result at path " + path + ": O=" + countOutsideConfig + " N="
+ countNoChange + " D=" + countDeleted + " A=" + countAdded
+ (LOG.isDebugEnabled() ? "\nDIFF at " + path + "\n" + diffLog : ""));
} else {
installLog.incCountAclsNoChange();
}
if (!hadPendingChanges) {
if (session.hasPendingChanges()) {
hadPendingChanges = true;
installLog.addMessage(LOG, "Path " + path + " introduced pending changes to the session");
}
}
}
// When using actions, it often happens that the second entry produced (with the rep:glob '*/jcr:content*') is a duplicate
// Also without this, a potential effective duplicate in config would be detected as change of incremental run when it is
// really not since jackrabbit ignores adding a duplicate entry to ACL
private Set<AceBean> filterDuplicates(Set<AceBean> aceBeanSetFromConfig, Session session)
throws UnsupportedRepositoryOperationException, RepositoryException {
LinkedHashSet<AceBean> filteredAceBeans = new LinkedHashSet<AceBean>(aceBeanSetFromConfig);
Iterator<AceBean> aceBeansIt = filteredAceBeans.iterator();
Set<String> aceCompareKeysToAvoidDuplicates = new HashSet<String>();
while (aceBeansIt.hasNext()) {
String aceCompareKey = toAceCompareString(aceBeansIt.next(), session.getAccessControlManager());
if (aceCompareKeysToAvoidDuplicates.contains(aceCompareKey)) {
aceBeansIt.remove();
} else {
aceCompareKeysToAvoidDuplicates.add(aceCompareKey);
}
}
return filteredAceBeans;
}
private Set<AceBean> filterInitialContentOnlyNodes(Set<AceBean> aceBeanSetFromConfig) {
Set<AceBean> aceBeanSetNoInitialContentOnlyNodes = new LinkedHashSet<AceBean>();
for (AceBean aceBean : aceBeanSetFromConfig) {
if (!aceBean.isInitialContentOnlyConfig()) {
aceBeanSetNoInitialContentOnlyNodes.add(aceBean);
}
}
return aceBeanSetNoInitialContentOnlyNodes;
}
// to be overwritten in JUnit Test
protected JackrabbitAccessControlList getAccessControlList(AccessControlManager acMgr, String path) throws RepositoryException {
JackrabbitAccessControlList acl = AccessControlUtils.getModifiableAcl(acMgr, path);
return acl;
}
private Set<AceBean> transformActionsIntoPrivileges(Set<AceBean> aceBeanSetFromConfig, Session session,
AcInstallationLog installLog) throws RepositoryException {
Set<AceBean> aceBeanSetWithPrivilegesOnly = new LinkedHashSet<AceBean>();
for (AceBean origAceBean : aceBeanSetFromConfig) {
if (origAceBean.getActionMap().isEmpty()) {
aceBeanSetWithPrivilegesOnly.add(origAceBean);
continue;
}
Set<AceBean> aceBeansForActionEntry = getPrincipalAceBeansForActionAceBeanCached(origAceBean, session, installLog);
for (AceBean aceBeanResolvedFromAction : aceBeansForActionEntry) {
aceBeanSetWithPrivilegesOnly.add(aceBeanResolvedFromAction);
}
}
return aceBeanSetWithPrivilegesOnly;
}
private Set<AceBean> getPrincipalAceBeansForActionAceBeanCached(AceBean origAceBean, Session session,
AcInstallationLog installLog) throws RepositoryException {
String cacheKey = (definesContent(origAceBean.getJcrPathForPolicyApi(), session) ? "definesContent" : "simple")
+ "-" + origAceBean.getPermission() + "-" + getRestrictionsComparable(origAceBean.getRestrictions()) + "-"
+ Arrays.toString(origAceBean.getActions());
if (actionsToPrivilegesMapping.containsKey(cacheKey)) {
installLog.incCountActionCacheHit();
LOG.trace("Cache hit for key " + cacheKey);
Set<AceBean> cachedAceBeansForActions = actionsToPrivilegesMapping.get(cacheKey);
Set<AceBean> principalCorrectedAceBeansForActions = new LinkedHashSet<AceBean>();
for (AceBean aceBean : cachedAceBeansForActions) {
AceBean clone = aceBean.clone();
clone.setPrincipal(origAceBean.getPrincipalName());
principalCorrectedAceBeansForActions.add(clone);
}
return principalCorrectedAceBeansForActions;
} else {
installLog.incCountActionCacheMiss();
Set<AceBean> aceBeansForActionEntry = null;
Session newSession = null;
try {
// a new session is needed to ensure no pending changes are introduced (even if there would not be real pending changes
// since we add and remove, but session.hasPendingChanges() is true then)
newSession = slingRepository.loginService(Constants.USER_AC_SERVICE, null);
aceBeansForActionEntry = getPrincipalAceBeansForActionAceBean(origAceBean, newSession);
} finally {
newSession.logout();
}
LOG.debug("Adding to cache: {}={}", cacheKey, aceBeansForActionEntry);
actionsToPrivilegesMapping.put(cacheKey, aceBeansForActionEntry);
return aceBeansForActionEntry;
}
}
Set<AceBean> getPrincipalAceBeansForActionAceBean(AceBean origAceBean, Session session) throws RepositoryException {
Set<AceBean> aceBeansForActionEntry = new LinkedHashSet<AceBean>();
String groupPrincipalId = "actool-tester-action-mapper"; // does not have to exist since the ACEs for it are not saved
Principal principal = applyCqActions(origAceBean, session, groupPrincipalId);
JackrabbitAccessControlList newAcl = getAccessControlList(session.getAccessControlManager(), origAceBean.getJcrPathForPolicyApi());
boolean isFirst = true;
for (AccessControlEntry newAce : newAcl.getAccessControlEntries()) {
if (!newAce.getPrincipal().equals(principal)) {
continue;
}
AceBean privilegesAceBeanForAction = AcHelper.getAceBean(newAce, newAcl);
privilegesAceBeanForAction.setPrincipal(origAceBean.getPrincipalName());
// handle restrictions
if (isFirst) {
if (origAceBean.containsRestriction(AceBean.RESTRICTION_NAME_GLOB)
&& privilegesAceBeanForAction.containsRestriction(AceBean.RESTRICTION_NAME_GLOB)) {
throw new IllegalArgumentException(
"When using actions that produce rep:glob restrictions (e.g. for page paths), rep:glob cannot be configured (origAceBean="
+ origAceBean.getRestrictions() + ", privilegesAceBeanForAction="
+ privilegesAceBeanForAction.getRestrictions() + "), check configuration for "
+ origAceBean);
} else {
// other restrictions are just taken over
privilegesAceBeanForAction.getRestrictions().addAll(origAceBean.getRestrictions());
}
}
aceBeansForActionEntry.add(privilegesAceBeanForAction);
// remove the fake entry again
newAcl.removeAccessControlEntry(newAce);
isFirst = false;
}
AccessControlManager acMgr = session.getAccessControlManager();
acMgr.setPolicy(origAceBean.getJcrPath(), newAcl);
// handle privileges
AceBean firstMappedBean = aceBeansForActionEntry.iterator().next(); // apply additional privileges only to first bean
Set<String> newPrivilegesFirstMappedBean = new LinkedHashSet<String>();
// first add regular privileges
if (firstMappedBean.getPrivileges() != null) {
newPrivilegesFirstMappedBean.addAll(Arrays.asList(firstMappedBean.getPrivileges()));
}
Set<String> flatSetPrincipalsOfFirstMappedBean = flatSetResolvedAggregates(firstMappedBean.getPrivileges(), acMgr, true);
if (origAceBean.getPrivileges() != null) {
for (String origBeanPrivString : origAceBean.getPrivileges()) {
if (!flatSetPrincipalsOfFirstMappedBean.contains(origBeanPrivString)) {
newPrivilegesFirstMappedBean.add(origBeanPrivString);
}
}
}
firstMappedBean.setPrivilegesString(StringUtils.join(newPrivilegesFirstMappedBean, ","));
if (LOG.isDebugEnabled()) {
StringBuilder buf = new StringBuilder();
buf.append("CqActions at path " + origAceBean.getJcrPath()
+ " with principal=" + origAceBean.getPrincipalName() + "/" + principal.getName() + " produced \n");
for (AceBean aceBean : aceBeansForActionEntry) {
buf.append(" " + toAceCompareString(aceBean, acMgr) + "\n");
}
LOG.debug(buf.toString());
}
return aceBeansForActionEntry;
}
Principal applyCqActions(AceBean origAceBean, Session session, String groupPrincipalId) throws RepositoryException {
CqActions cqActions = new CqActions(session);
Principal principal = new PrincipalImpl(groupPrincipalId);
Collection<String> inheritedAllows = cqActions.getAllowedActions(origAceBean.getJcrPathForPolicyApi(),
Collections.singleton(principal));
// this does always install new entries
cqActions.installActions(origAceBean.getJcrPath(), principal, origAceBean.getActionMap(), inheritedAllows);
return principal;
}
private Set<String> flatSetResolvedAggregates(String[] privNames, AccessControlManager acMgr, boolean includeAggregates)
throws RepositoryException {
if (privNames == null) {
return Collections.emptySet();
}
final Set<String> privileges = new HashSet<String>();
for (final String name : privNames) {
final Privilege p = acMgr.privilegeFromName(name);
if (!p.isAggregate() || includeAggregates) {
privileges.add(p.getName());
}
if (p.isAggregate()) { // add "sub privileges" as well
for (Privilege subPriv : p.getDeclaredAggregatePrivileges()) {
Set<String> subPrivileges = flatSetResolvedAggregates(new String[] { subPriv.getName() }, acMgr, includeAggregates);
privileges.addAll(subPrivileges);
}
}
}
return privileges;
}
boolean definesContent(String pagePath, Session session) throws RepositoryException {
if (pagePath == null || pagePath.equals("/")) {
return false;
}
try {
return CqActions.definesContent(session.getNode(pagePath));
} catch (PathNotFoundException e) {
return false;
}
}
private String toAceCompareString(AceBean aceBean, AccessControlManager acMgr) throws RepositoryException {
if (aceBean == null) {
return "null";
}
List<Restriction> restrictionsSorted = getRestrictionsComparable(aceBean.getRestrictions());
String nonAggregatePrivsNormalized = privilegesToComparableSet(aceBean.getPrivileges(), acMgr);
String aceCompareStr = aceBean.getPrincipalName() + " " + aceBean.getPermission() + " " + nonAggregatePrivsNormalized
+ Arrays.toString(restrictionsSorted.toArray());
return aceCompareStr;
}
private List<Restriction> getRestrictionsComparable(List<Restriction> restrictions) {
List<Restriction> restrictionsSorted = new ArrayList<Restriction>(restrictions);
Collections.sort(restrictionsSorted, new Comparator<Restriction>() {
@Override
public int compare(Restriction r1, Restriction r2) {
return Collator.getInstance().compare(r1.getName(), r2.getName());
}
});
return restrictionsSorted;
}
String privilegesToComparableSet(String[] privileges, AccessControlManager acMgr) throws RepositoryException {
return new TreeSet<String>(flatSetResolvedAggregates(privileges, acMgr, false)).toString();
}
}