/** * 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.tenants; import java.io.File; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import javax.annotation.PostConstruct; import javax.xml.transform.Source; import org.apereo.portal.io.xml.IDataTemplatingStrategy; import org.apereo.portal.io.xml.IPortalDataHandlerService; import org.apereo.portal.io.xml.IPortalDataType; import org.apereo.portal.io.xml.PortalDataKey; import org.apereo.portal.io.xml.SpELDataTemplatingStrategy; import org.apereo.portal.io.xml.group.GroupMembershipPortalDataType; import org.apereo.portal.io.xml.pags.PersonAttributesGroupStorePortalDataType; import org.apereo.portal.spring.spel.IPortalSpELService; import org.apereo.portal.spring.spel.PortalSpELServiceImpl; import org.dom4j.Attribute; import org.dom4j.Document; import org.dom4j.QName; import org.dom4j.io.SAXReader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.expression.Expression; import org.springframework.expression.spel.support.StandardEvaluationContext; /** * Uses Import/Export to create some basic portal data for a new tenant. You can add to, adjust, or * pare-down tenant template data in src/main/resources/org/apereo/portal/tenants/data. * * @since 4.1 */ public final class TemplateDataTenantOperationsListener extends AbstractTenantOperationsListener implements ApplicationContextAware { private static final String TENANT_ENTITIES_IMPORTED = "tenant.entities.imported"; private static final String IMPORTED_THE_FOLLOWING_ENTITIES = "imported.the.following.entities"; private static final String DELETED_THE_FOLLOWING_ENTITIES = "deleted.the.following.entities"; private static final String UNABLE_TO_DELETE_THE_FOLLOWING_ENTITIES = "unable.to.delete.the.following.entities"; private static final String FAILED_TO_LOAD_TENANT_TEMPLATE = "failed.to.load.tenant.template"; private static final String FAILED_TO_IMPORT_TENANT_TEMPLATE_DATA = "failed.to.import.tenant.template.data"; private static final Set<PortalDataKey> KEYS_TO_IGNORE = new HashSet<>( Arrays.asList( new PortalDataKey[] { GroupMembershipPortalDataType.IMPORT_32_DATA_KEY, PersonAttributesGroupStorePortalDataType.IMPORT_PAGS_41_DATA_KEY })); private ApplicationContext applicationContext; private SAXReader reader; private static final Logger log = LoggerFactory.getLogger(TemplateDataTenantOperationsListener.class); @Autowired private IPortalDataHandlerService dataHandlerService; @Autowired private IPortalSpELService portalSpELService; private List<PortalDataKey> dataKeyImportOrder = Collections.emptyList(); @Value( "${org.apereo.portal.tenants.TemplateDataTenantOperationsListener.templateLocation:classpath:/org/apereo/portal/tenants/data/**/*.xml}") private String templateLocation; // Evaluated by scanning a package (see 'templateLocation' above) private Set<Resource> entityResourcesToImportOnCreate; // Optionally specified in the <bean> definition private Set<String> entityResourcePathsToImportOnUpdate = Collections.emptySet(); // default private Set<Resource> entityResourcesToImportOnUpdate = Collections.emptySet(); // default private List<DeleteTuple> entitiesToRemoveOnDelete = Collections.emptyList(); // default public TemplateDataTenantOperationsListener() { super("template-data"); this.reader = new SAXReader(); this.reader.setMergeAdjacentText(true); } /** Order in which data types should be imported. */ @javax.annotation.Resource(name = "dataTypeImportOrder") public void setDataTypeImportOrder(List<IPortalDataType> dataTypeImportOrder) { final ArrayList<PortalDataKey> dataKeyImportOrder = new ArrayList<PortalDataKey>(dataTypeImportOrder.size() * 2); for (final IPortalDataType portalDataType : dataTypeImportOrder) { final List<PortalDataKey> supportedDataKeys = portalDataType.getDataKeyImportOrder(); for (final PortalDataKey portalDataKey : supportedDataKeys) { /* * Special Handling: Need to prevent some keys from entering our * sorted collection because they attempt to import both a group * part and the membership parts of a group (either local or PAGS) * in one go. We import several entities at once, so it's important * to do these in 2 phases. */ if (!KEYS_TO_IGNORE.contains(portalDataKey)) { dataKeyImportOrder.add(portalDataKey); } } } dataKeyImportOrder.trimToSize(); this.dataKeyImportOrder = Collections.unmodifiableList(dataKeyImportOrder); } public void setEntityResourcesToImportOnUpdate(Set<String> entityResourcesToImportOnUpdate) { // applicationContext not available to create resources immediately // simply capturing and resolving full paths to resources for setup() entityResourcePathsToImportOnUpdate = determineImportOnUpdatePaths(templateLocation, entityResourcesToImportOnUpdate); } /** * Determine resources based on defined template location and context resource values in * servicesContext.xml. If the values are relative, prepend template location path. This is * determined by checking that the value starts with "\[a-zA-Z]+:". * * @param templateLoc tenant template location defined in portal.properties * @param relResourcePathSet importOnUpdate values defined in servicesContext.xml * @return resource paths as absolute paths. */ /*package*/ static Set<String> determineImportOnUpdatePaths( String templateLoc, Set<String> relResourcePathSet) { String templateLocPath = templateLoc.split("\\*")[0]; // up to wildcard pattern if (!templateLocPath.endsWith("/")) { templateLocPath = templateLocPath + "/"; } final Set<String> fullResourcePathSet = new HashSet<>(); for (String resourcePath : relResourcePathSet) { final String fullPath; try { fullPath = resourcePath.matches("^[a-zA-Z]+:.*") ? resourcePath : (new URI(templateLocPath).resolve(resourcePath)).toString(); } catch (URISyntaxException e) { throw new RuntimeException("Unable to construct a URI by resolving '" + resourcePath + "'from '" + templateLocPath + "'"); } log.debug("Calculated full path: {} -> {}", resourcePath, fullPath); fullResourcePathSet.add(fullPath); } return fullResourcePathSet; } /** * Given the list of resource strings, acquire the set of resource objects from a {@code * org.springframework.core.io.ResourceLoader}. * * @param resourceLoader a resource loader, usually an application context * @param resourcePaths set of resource paths as strings * @return set of resources derived from paths via resource loader */ /*package*/ static Set<Resource> buildResourcesFromPaths( ResourceLoader resourceLoader, Set<String> resourcePaths) { final Set<Resource> resourceSet = new HashSet<>(); for (String resourcePath : resourcePaths) { Resource resource = resourceLoader.getResource(resourcePath); log.debug("Resource {} exists?: {}", resource, resource.exists()); resourceSet.add(resource); } return resourceSet; } public void setEntitiesToRemoveOnDelete(List<DeleteTuple> entitiesToRemoveOnDelete) { this.entitiesToRemoveOnDelete = entitiesToRemoveOnDelete; } @Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } @PostConstruct public void setup() throws Exception { entityResourcesToImportOnCreate = new HashSet<Resource>( Arrays.asList(applicationContext.getResources(templateLocation))); this.entityResourcesToImportOnUpdate = buildResourcesFromPaths(applicationContext, entityResourcePathsToImportOnUpdate); } @Override public TenantOperationResponse onCreate(final ITenant tenant) { return importWithResources(tenant, entityResourcesToImportOnCreate); } @Override public TenantOperationResponse onUpdate(final ITenant tenant) { return importWithResources(tenant, entityResourcesToImportOnUpdate); } @Override public TenantOperationResponse onDelete(final ITenant tenant) { // Deleting is optional; we should IGNORE this whole // business if no items are specified for delete if (entitiesToRemoveOnDelete.isEmpty()) { return super.onDelete(tenant); } TenantOperationResponse.Result result = TenantOperationResponse.Result.SUCCESS; // detault // We will prepare a list of items that succeeded... final StringBuilder successfulEntitiesMessage = new StringBuilder(); successfulEntitiesMessage .append(createLocalizedMessage(DELETED_THE_FOLLOWING_ENTITIES, null)) .append("\n<ul>"); // And one for items that failed, if any. final StringBuilder failedEntitiesMessage = new StringBuilder(); failedEntitiesMessage .append(createLocalizedMessage(UNABLE_TO_DELETE_THE_FOLLOWING_ENTITIES, null)) .append("\n<ul>"); // We support SpEL expressions in the sysid parameter (we don't have a choice) final StandardEvaluationContext ctx = new StandardEvaluationContext(); ctx.setRootObject(new RootObjectImpl(tenant)); boolean didAtLeastOneCommandSucceed = false; // until we know different for (DeleteTuple tuple : entitiesToRemoveOnDelete) { final Expression x = portalSpELService.parseExpression( tuple.getSysid(), PortalSpELServiceImpl.TemplateParserContext.INSTANCE); final String sysid = x.getValue(ctx, String.class); try { dataHandlerService.deleteData(tuple.getType(), sysid); successfulEntitiesMessage .append("\n <li><span class=\"label label-info\">") .append(tuple.getType()) .append("</span>") .append(" ") .append(sysid) .append("</li>"); didAtLeastOneCommandSucceed = true; } catch (Exception e) { log.error( "Failed to process the specified delete command: type={}, sysid={}", tuple.getType(), tuple.getSysid(), e); failedEntitiesMessage .append("\n <li><span class=\"label label-info\">") .append(tuple.getType()) .append("</span>") .append(" ") .append(sysid) .append("</li>"); result = TenantOperationResponse.Result .FAIL; // We will allow subsequent listeners to follow through } } // Finish our HTML lists... successfulEntitiesMessage.append("\n</ul>"); failedEntitiesMessage.append("\n</ul>"); final TenantOperationResponse rslt = new TenantOperationResponse(this, result); // Did we succeed at all? if (didAtLeastOneCommandSucceed) { rslt.addMessage(successfulEntitiesMessage.toString()); } switch (result) { // Did we fail at all? case FAIL: rslt.addMessage(failedEntitiesMessage.toString()); break; // Or succeed completely? default: // Might add another message here in future... break; } return rslt; } /* * Implementation */ /** * High-level implementation method that brokers the queuing, importing, and reporting that is * common to Create and Update. */ public TenantOperationResponse importWithResources( final ITenant tenant, final Set<Resource> resources) { /* * First load dom4j Documents and sort the entity files into the proper order */ final Map<PortalDataKey, Set<BucketTuple>> importQueue; try { importQueue = prepareImportQueue(tenant, resources); } catch (Exception e) { final TenantOperationResponse error = new TenantOperationResponse(this, TenantOperationResponse.Result.ABORT); error.addMessage( createLocalizedMessage( FAILED_TO_LOAD_TENANT_TEMPLATE, new String[] {tenant.getName()})); return error; } log.trace( "Ready to import data entity templates for new tenant '{}'; importQueue={}", tenant.getName(), importQueue); // We're going to report on every item imported; TODO it would be better // if we could display human-friendly entity type name + sysid (fname, etc.) final StringBuilder importReport = new StringBuilder(); /* * Now import the identified entities each bucket in turn */ try { importQueue(tenant, importQueue, importReport); } catch (Exception e) { final TenantOperationResponse error = new TenantOperationResponse(this, TenantOperationResponse.Result.ABORT); error.addMessage(finalizeImportReport(importReport)); error.addMessage( createLocalizedMessage( FAILED_TO_IMPORT_TENANT_TEMPLATE_DATA, new String[] {tenant.getName()})); return error; } TenantOperationResponse rslt = new TenantOperationResponse(this, TenantOperationResponse.Result.SUCCESS); rslt.addMessage(finalizeImportReport(importReport)); rslt.addMessage( createLocalizedMessage(TENANT_ENTITIES_IMPORTED, new String[] {tenant.getName()})); return rslt; } /** Loads dom4j Documents and sorts the entity files into the proper order for Import. */ private Map<PortalDataKey, Set<BucketTuple>> prepareImportQueue( final ITenant tenant, final Set<Resource> templates) throws Exception { final Map<PortalDataKey, Set<BucketTuple>> rslt = new HashMap<PortalDataKey, Set<BucketTuple>>(); Resource rsc = null; try { for (Resource r : templates) { rsc = r; if (log.isDebugEnabled()) { log.debug( "Loading template resource file for tenant " + "'" + tenant.getFname() + "': " + rsc.getFilename()); } final Document doc = reader.read(rsc.getInputStream()); PortalDataKey atLeastOneMatchingDataKey = null; for (PortalDataKey pdk : dataKeyImportOrder) { boolean matches = evaluatePortalDataKeyMatch(doc, pdk); if (matches) { // Found the right bucket... log.debug("Found PortalDataKey '{}' for data document {}", pdk, r.getURI()); atLeastOneMatchingDataKey = pdk; Set<BucketTuple> bucket = rslt.get(atLeastOneMatchingDataKey); if (bucket == null) { // First of these we've seen; create the bucket; bucket = new HashSet<BucketTuple>(); rslt.put(atLeastOneMatchingDataKey, bucket); } BucketTuple tuple = new BucketTuple(rsc, doc); bucket.add(tuple); /* * At this point, we would normally add a break; * statement, but group_membership.xml files need to * match more than one PortalDataKey. */ } } if (atLeastOneMatchingDataKey == null) { // We can't proceed throw new RuntimeException( "No PortalDataKey found for QName: " + doc.getRootElement().getQName()); } } } catch (Exception e) { log.error( "Failed to process the specified template: {}", (rsc != null ? rsc.getFilename() : "null"), e); throw e; } return rslt; } /** Imports the specified entities in the proper order. */ private void importQueue( final ITenant tenant, final Map<PortalDataKey, Set<BucketTuple>> queue, final StringBuilder importReport) throws Exception { final StandardEvaluationContext ctx = new StandardEvaluationContext(); ctx.setRootObject(new RootObjectImpl(tenant)); IDataTemplatingStrategy templating = new SpELDataTemplatingStrategy(portalSpELService, ctx); Document doc = null; try { for (PortalDataKey pdk : dataKeyImportOrder) { Set<BucketTuple> bucket = queue.get(pdk); if (bucket != null) { log.debug( "Importing the specified PortalDataKey tenant '{}': {}", tenant.getName(), pdk.getName()); for (BucketTuple tuple : bucket) { doc = tuple.getDocument(); Source data = templating.processTemplates( doc, tuple.getResource().getURL().toString()); dataHandlerService.importData(data, pdk); importReport.append(createImportReportLineItem(pdk, tuple)); } } } } catch (Exception e) { log.error( "Failed to process the specified template document:\n{}", (doc != null ? doc.asXML() : "null"), e); throw e; } } private boolean evaluatePortalDataKeyMatch(Document doc, PortalDataKey pdk) { // Matching is tougher because it's dom4j <> w3c... final QName qname = doc.getRootElement().getQName(); if (!qname.getName().equals(pdk.getName().getLocalPart()) || !qname.getNamespaceURI().equals(pdk.getName().getNamespaceURI())) { // Rule these out straight off... return false; } // If the PortalDataKey declares a script // (old method), the document must match it final Attribute s = doc.getRootElement().attribute(PortalDataKey.SCRIPT_ATTRIBUTE_NAME.getLocalPart()); final String script = s != null ? s.getValue() : null; if (pdk.getScript() != null) { // If the pdk declares a script, the data document MUST match it... if (pdk.getScript().equals(script)) { /* * A data document that matches on script need not match on version * as well. It appears that the pdk.version member is overloaded * with two purposes... * * - A numeric version (e.g. 4.0) indicates a match IN THE ABSENCE * OF a script attribute (newer method, below) * - A word (e.g. 'GROUP' or 'MEMBERS') indicates the type of data * the pdk handles, where more than one pdk applies to the data * document */ return true; } else { return false; } } // If the PortalDataKey declares a version BUT NOT a script (new // method), the document must match it final Attribute v = doc.getRootElement().attribute(PortalDataKey.VERSION_ATTRIBUTE_NAME.getLocalPart()); final String version = v != null ? v.getValue() : null; if (pdk.getVersion() != null && pdk.getVersion().equals(version)) { return true; } // This pdk is not a match return false; } private String createImportReportLineItem(PortalDataKey pdk, BucketTuple tuple) { final String versionPart = pdk.getVersion() != null ? " (" + pdk.getVersion() + ")" : ""; StringBuilder rslt = new StringBuilder(); rslt.append("\n <li><span class=\"label label-info\">") .append(pdk.getName().getLocalPart()) .append(versionPart) .append("</span>") .append(" ") .append(tuple.getResource().getFilename()) .append("</li>"); return rslt.toString(); } private String finalizeImportReport(StringBuilder message) { final String preamble = createLocalizedMessage(IMPORTED_THE_FOLLOWING_ENTITIES, null); message.insert(0, "\n<ul>").insert(0, preamble); // Reverse order message.append("\n</ul>"); return message.toString(); } /* * Nested Types */ /** Used in the implementation of onDelete. */ public static final class DeleteTuple { private String type; private String sysid; public String getType() { return type; } public void setType(String type) { this.type = type; } public String getSysid() { return sysid; } public void setSysid(String sysid) { this.sysid = sysid; } } private static final class BucketTuple { private final Resource resource; private final Document document; public BucketTuple(Resource resource, Document document) { this.resource = resource; this.document = document; } public Resource getResource() { return resource; } public Document getDocument() { return document; } } private static final class RootObjectImpl { private final ITenant tenant; public RootObjectImpl(ITenant tenant) { this.tenant = tenant; } @SuppressWarnings("unused") public ITenant getTenant() { return tenant; } } }