/** * The contents of this file are subject to the license and copyright * detailed in the LICENSE and NOTICE files at the root of the source * tree and available online at * * http://www.dspace.org/license/ */ package org.dspace.content.crosswalk; import java.io.IOException; import java.sql.SQLException; import java.text.ParseException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Date; import java.text.SimpleDateFormat; import org.apache.commons.lang.ArrayUtils; import org.apache.commons.lang.StringUtils; import org.apache.log4j.Logger; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.factory.AuthorizeServiceFactory; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.content.DSpaceObject; import org.dspace.content.packager.PackageException; import org.dspace.content.packager.PackageUtils; import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; import org.dspace.eperson.factory.EPersonServiceFactory; import org.dspace.eperson.service.EPersonService; import org.dspace.eperson.service.GroupService; import org.jdom.Element; import org.jdom.Namespace; /** * METSRights Ingestion and Dissemination Crosswalk * <p> * Translate between DSpace internal policies (i.e. permissions) and the * METSRights metadata schema * (see <a href="http://www.loc.gov/standards/rights/METSRights.xsd"> * http://www.loc.gov/standards/rights/METSRights.xsd</a> for details). * <p> * Examples of METSRights usage available from: * <a href="http://www.loc.gov/standards/rights/"> * http://www.loc.gov/standards/rights/</a> * <p> * This Crosswalk provides a way to export DSpace permissions into a standard * format, and then re-import or restore them into a DSpace instance. * * @author Tim Donohue * @version $Revision: 2108 $ */ public class METSRightsCrosswalk implements IngestionCrosswalk, DisseminationCrosswalk { /** log4j category */ private static Logger log = Logger.getLogger(METSRightsCrosswalk.class); private static final Namespace METSRights_NS = Namespace.getNamespace("rights", "http://cosimo.stanford.edu/sdr/metsrights/"); // XML schemaLocation fragment for this crosswalk, from config. private String schemaLocation = METSRights_NS.getURI()+" http://cosimo.stanford.edu/sdr/metsrights.xsd"; private static final Namespace namespaces[] = { METSRights_NS }; private static final Map<Integer,String> otherTypesMapping = new HashMap<Integer,String>(); static { //Mapping of DSpace Policy Actions to METSRights PermissionType values // (These are the values stored in the @OTHERPERMITTYPE attribute in METSRights) // NOTE: READ, WRITE, DELETE are not included here as they map directly to existing METSRights PermissionTypes otherTypesMapping.put(Constants.ADD, "ADD CONTENTS"); otherTypesMapping.put(Constants.REMOVE, "REMOVE CONTENTS"); otherTypesMapping.put(Constants.ADMIN, "ADMIN"); otherTypesMapping.put(Constants.DEFAULT_BITSTREAM_READ, "READ FILE CONTENTS"); otherTypesMapping.put(Constants.DEFAULT_ITEM_READ, "READ ITEM CONTENTS"); } // Value of METSRights <Context> @CONTEXTCLASS attribute to use for DSpace Groups private static final String GROUP_CONTEXTCLASS = "MANAGED GRP"; // Value of METSRights <Context> @CONTEXTCLASS attribute to use for DSpace EPeople private static final String PERSON_CONTEXTCLASS = "ACADEMIC USER"; // Value of METSRights <Context> @CONTEXTCLASS attribute to use for "Anonymous" DSpace Group private static final String ANONYMOUS_CONTEXTCLASS = "GENERAL PUBLIC"; // Value of METSRights <Context> @CONTEXTCLASS attribute to use for "Administrator" DSpace Group private static final String ADMIN_CONTEXTCLASS = "REPOSITORY MGR"; // Value of METSRights <UserName> @USERTYPE attribute to use for DSpace Groups private static final String GROUP_USERTYPE = "GROUP"; // Value of METSRights <UserName> @USERTYPE attribute to use for DSpace Groups private static final String PERSON_USERTYPE = "INDIVIDUAL"; protected AuthorizeService authorizeService = AuthorizeServiceFactory.getInstance().getAuthorizeService(); protected EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); protected GroupService groupService = EPersonServiceFactory.getInstance().getGroupService(); protected ResourcePolicyService resourcePolicyService = AuthorizeServiceFactory.getInstance().getResourcePolicyService(); /*----------- Dissemination functions -------------------*/ @Override public Namespace[] getNamespaces() { return (Namespace[]) ArrayUtils.clone(namespaces); } @Override public String getSchemaLocation() { return schemaLocation; } @Override public boolean canDisseminate(DSpaceObject dso) { //can disseminate all types of DSpace Objects, except for SITE return (dso.getType()!=Constants.SITE); } /** * Actually Disseminate into METSRights schema. This method locates all DSpace * policies (permissions) for the provided object, and translates them into * METSRights PermissionTypes. * * @param context context * @param dso DSpace Object * @return XML Element corresponding to the new {@code <RightsDeclarationMD>} translation * @throws CrosswalkException if crosswalk error * @throws IOException if IO error * @throws SQLException if database error * @throws AuthorizeException if authorization error */ @Override public Element disseminateElement(Context context, DSpaceObject dso) throws CrosswalkException, IOException, SQLException, AuthorizeException { if(dso==null) { return null; } // we don't have a way to provide METSRights for a SITE object else if(dso.getType() == Constants.SITE) { throw new CrosswalkObjectNotSupported("The METSRightsCrosswalk cannot crosswalk a SITE object"); } //Root element: RightsDeclarationMD // All DSpace content is just under LICENSE -- no other rights can be claimed Element rightsMD = new Element("RightsDeclarationMD", METSRights_NS); rightsMD.setAttribute("RIGHTSCATEGORY", "LICENSED"); //Three sections to METSRights: // * RightsDeclaration - general rights statement // * RightsHolder - info about who owns rights // * Context - info about specific permissions granted // We're just crosswalking DSpace policies to "Context" permissions by default // It's too difficult to make statements about who owns the rights and // what those rights are -- too many types of content can be stored in DSpace //Get all policies on this DSpace Object List<ResourcePolicy> policies = authorizeService.getPolicies(context, dso); //For each DSpace policy for(ResourcePolicy policy : policies) { // DSpace Policies can either reference a Group or an Individual, but not both! Group group = policy.getGroup(); EPerson person = policy.getEPerson(); // Create our <Context> node for this policy Element rightsContext = new Element("Context", METSRights_NS); String rpName = policy.getRpName(); if (rpName != null) { rightsContext.setAttribute("rpName",rpName); } // As of DSpace 3.0, policies may have an effective date range, check if a policy is effective rightsContext.setAttribute("in-effect","true"); Date now = new Date(); SimpleDateFormat iso8601 = new SimpleDateFormat("yyyy-MM-dd"); if (policy.getStartDate() != null) { rightsContext.setAttribute("start-date", iso8601.format(policy.getStartDate())); if (policy.getStartDate().after(now)) { rightsContext.setAttribute("in-effect","false"); } } if (policy.getEndDate() != null) { rightsContext.setAttribute("end-date", iso8601.format(policy.getEndDate())); if (policy.getEndDate().before(now)) { rightsContext.setAttribute("in-effect","false"); } } //First, handle Group-based policies // For Group policies we need to setup a // <Context CONTEXTCLASS='[group-type]'><UserName USERTYPE='GROUP'>[group-name]</UserName>... if(group != null) { //Default all DSpace groups to have "MANAGED GRP" as the type String contextClass=GROUP_CONTEXTCLASS; if(StringUtils.equals(group.getName(), Group.ANONYMOUS)) //DSpace Anonymous Group = 'GENERAL PUBLIC' type { contextClass = ANONYMOUS_CONTEXTCLASS; } else if(StringUtils.equals(group.getName(), Group.ADMIN)) //DSpace Administrator Group = 'REPOSITORY MGR' type { contextClass = ADMIN_CONTEXTCLASS; } rightsContext.setAttribute("CONTEXTCLASS", contextClass); //If this is a "MANAGED GRP", then create a <UserName> child //to specify the group Name, and set @USERTYPE='GROUP' if(contextClass.equals(GROUP_CONTEXTCLASS)) { try { //Translate the Group name for export. This ensures that groups with Internal IDs in their names // (e.g. COLLECTION_1_ADMIN) are properly translated using the corresponding Handle or external identifier. String exportGroupName = PackageUtils.translateGroupNameForExport(context, group.getName()); //If translated group name is returned as "null", this means the Group name // had an Internal Collection/Community ID embedded, which could not be // translated properly to a Handle. We will NOT export these groups, // as they could cause conflicts or data integrity problems if they are // imported into another DSpace system. if(exportGroupName!=null && !exportGroupName.isEmpty()) { //Create <UserName USERTYPE='GROUP'> element. Add the Group's name to that element Element rightsUser = new Element("UserName", METSRights_NS); rightsUser.setAttribute("USERTYPE",GROUP_USERTYPE); rightsUser.addContent(exportGroupName); rightsContext.addContent(rightsUser); } else //Skip over this Group, as we couldn't translate it for export. //The Group seems to refer to a Community or Collection which no longer exists continue; } catch(PackageException pe) { //A PackageException will only be thrown if translateGroupNameForExport() fails //We'll just wrap it as a CrosswalkException and throw it upwards throw new CrosswalkException(pe); } } rightsMD.addContent(rightsContext); }//end if group //Next, handle User-based policies // For User policies we need to setup a // <Context CONTEXTCLASS='ACADEMIC USER'><UserName USERTYPE='INDIVIDUAL'>[group-name]</UserName>... else if(person!=null) { // All EPeople are considered 'Academic Users' rightsContext.setAttribute("CONTEXTCLASS", PERSON_CONTEXTCLASS); //Create a <UserName> node corresponding to person's email, set @USERTYPE='INDIVIDUAL' Element rightsUser = new Element("UserName", METSRights_NS); rightsUser.setAttribute("USERTYPE",PERSON_USERTYPE); rightsUser.addContent(person.getEmail()); rightsContext.addContent(rightsUser); rightsMD.addContent(rightsContext); }//end if person else log.error("Policy " + String.valueOf(policy.getID()) + " is neither user nor group! Omitted from package."); //Translate the DSpace ResourcePolicy into a <Permissions> element Element rightsPerm = translatePermissions(policy); rightsContext.addContent(rightsPerm); }//end for each policy return rightsMD; } @Override public List<Element> disseminateList(Context context, DSpaceObject dso) throws CrosswalkException, IOException, SQLException, AuthorizeException { List<Element> result = new ArrayList<Element>(1); result.add(disseminateElement(context, dso)); return result; } @Override public boolean preferList() { return false; } /** * Translates a DSpace ResourcePolicy's permissions into a METSRights * <code>Permissions</code> element. Returns the created * <code>Permissions</code> element. This element may be empty if * there was an issue translating the ResourcePolicy. * * @param policy The DSpace ResourcePolicy * @return the Element representing the METSRIghts <code>Permissions</code> or null. */ private Element translatePermissions(ResourcePolicy policy) { //Create our <Permissions> node to store all permissions in this context Element rightsPerm = new Element("Permissions", METSRights_NS); //Determine the 'actions' permitted by this DSpace policy, and translate to METSRights PermissionTypes int action = policy.getAction(); //All READ-based actions = cannot modify or delete object if(action==Constants.READ || action==Constants.DEFAULT_BITSTREAM_READ || action==Constants.DEFAULT_ITEM_READ) { // For DSpace, READ = Discover and Display rightsPerm.setAttribute("DISCOVER", "true"); rightsPerm.setAttribute("DISPLAY", "true"); //Read = cannot modify or delete rightsPerm.setAttribute("MODIFY", "false"); rightsPerm.setAttribute("DELETE", "false"); } //All WRITE-based actions = can modify, but cannot delete else if(action == Constants.WRITE || action==Constants.ADD) { rightsPerm.setAttribute("DISCOVER", "true"); rightsPerm.setAttribute("DISPLAY", "true"); //Write = can modify, but cannot delete rightsPerm.setAttribute("MODIFY", "true"); rightsPerm.setAttribute("DELETE", "false"); } //All DELETE-based actions = can modify & can delete //(NOTE: Although Constants.DELETE is marked as "obsolete", it is still used in dspace-api) else if(action == Constants.DELETE || action==Constants.REMOVE) { rightsPerm.setAttribute("DISCOVER", "true"); rightsPerm.setAttribute("DISPLAY", "true"); //Delete = can both modify and delete rightsPerm.setAttribute("MODIFY", "true"); rightsPerm.setAttribute("DELETE", "true"); } //ADMIN action = full permissions else if(action == Constants.ADMIN) { rightsPerm.setAttribute("DISCOVER", "true"); rightsPerm.setAttribute("DISPLAY", "true"); rightsPerm.setAttribute("COPY", "true"); rightsPerm.setAttribute("DUPLICATE", "true"); rightsPerm.setAttribute("MODIFY", "true"); rightsPerm.setAttribute("DELETE", "true"); rightsPerm.setAttribute("PRINT", "true"); } else { //Unknown action -- don't enable any rights by default //NOTE: ALL WORKFLOW RELATED ACTIONS ARE NOT INCLUDED IN METSRIGHTS //DSpace API no longer assigns nor checks any of the following 'action' types: // * Constants.WORKFLOW_STEP_1 // * Constants.WORKFLOW_STEP_2 // * Constants.WORKFLOW_STEP_3 // * Constants.WORKFLOW_ABORT }//end if //Also add in OTHER permissionTypes, as necessary (see 'otherTypesMapping' above) // (These OTHER permissionTypes are used to tell apart similar DSpace permissions during Ingestion) if(otherTypesMapping.containsKey(action)) { //if found in our 'otherTypesMapping', enable @OTHER attribute and add in the appropriate value to @OTHERPERMITTYPE attribute rightsPerm.setAttribute("OTHER", "true"); rightsPerm.setAttribute("OTHERPERMITTYPE", otherTypesMapping.get(action)); } return rightsPerm; } /*----------- Ingestion functions -------------------*/ /** * Ingest a whole XML document, starting at specified root. * * @param context * The relevant DSpace Context. * @param dso * DSpace object to ingest * @param root * root element * @param createMissingMetadataFields * whether to create missing fields * @throws CrosswalkException if crosswalk error * @throws IOException if IO error * @throws SQLException if database error * @throws AuthorizeException if authorization error */ @Override public void ingest(Context context, DSpaceObject dso, Element root, boolean createMissingMetadataFields) throws CrosswalkException, IOException, SQLException, AuthorizeException { if (!(root.getName().equals("RightsDeclarationMD"))) { throw new MetadataValidationException("Wrong root element for METSRights: " + root.toString()); } ingest(context, dso, root.getChildren(), createMissingMetadataFields); } /** * Ingest a List of XML elements * <P> * This method creates new DSpace Policies based on the parsed * METSRights XML contents. These Policies assign permissions * to DSpace Groups or EPeople. * <P> * NOTE: This crosswalk will NOT create missing DSpace Groups or EPeople. * Therefore, it is recommended to use this METSRightsCrosswalk in * conjunction with another Crosswalk which can create/restore missing * Groups or EPeople (e.g. RoleCrosswalk). * * @param context context * @param dso Dspace object * @param ml list of elements * @param createMissingMetadataFields whether to create missing fields * @throws CrosswalkException if crosswalk error * @throws IOException if IO error * @throws SQLException if database error * @throws AuthorizeException if authorization error * @see RoleCrosswalk */ @Override public void ingest(Context context, DSpaceObject dso, List<Element> ml, boolean createMissingMetadataFields) throws CrosswalkException, IOException, SQLException, AuthorizeException { // SITE objects are not supported by the METSRightsCrosswalk if (dso.getType() == Constants.SITE) { throw new CrosswalkObjectNotSupported("Wrong target object type, METSRightsCrosswalk cannot crosswalk a SITE object."); } // If we're fed the top-level <RightsDeclarationMD> wrapper element, recurse into its guts. // What we need to analyze are the <Context> elements underneath it. if(!ml.isEmpty() && ml.get(0).getName().equals("RightsDeclarationMD")) { ingest(context, dso, ml.get(0).getChildren(), createMissingMetadataFields); } else { // Loop through each <Context> Element in the passed in List, creating a ResourcePolicy for each List<ResourcePolicy> policies = new ArrayList<>(); for (Element element : ml) { // Must be a "Context" section (where permissions are stored) if (element.getName().equals("Context")) { //get what class of context this is String contextClass = element.getAttributeValue("CONTEXTCLASS"); ResourcePolicy rp = resourcePolicyService.create(context); SimpleDateFormat sdf = new SimpleDateFormat( "yyyy-MM-dd" ); // get reference to the <Permissions> element // Note: we are assuming here that there will only ever be ONE <Permissions> // element. Currently there are no known use cases for multiple. Element permsElement = element.getChild("Permissions", METSRights_NS); if(permsElement == null) { log.error("No <Permissions> element was found. Skipping this <Context> element."); continue; } if (element.getAttributeValue("rpName") != null) { rp.setRpName(element.getAttributeValue("rpName")); } try { if (element.getAttributeValue("start-date") != null) { rp.setStartDate(sdf.parse(element.getAttributeValue("start-date"))); } if (element.getAttributeValue("end-date") != null) { rp.setEndDate(sdf.parse(element.getAttributeValue("end-date"))); } }catch (ParseException ex) { log.error("Failed to parse embargo date. The date needs to be in the format 'yyyy-MM-dd'.", ex); } //Check if this permission pertains to Anonymous users if(ANONYMOUS_CONTEXTCLASS.equals(contextClass)) { //get DSpace Anonymous group, ID=0 Group anonGroup = groupService.findByName(context, Group.ANONYMOUS); if(anonGroup==null) { throw new CrosswalkInternalException("The DSpace database has not been properly initialized. The Anonymous Group is missing from the database."); } rp.setGroup(anonGroup); } // else if this permission declaration pertains to Administrators else if(ADMIN_CONTEXTCLASS.equals(contextClass)) { //get DSpace Administrator group, ID=1 Group adminGroup = groupService.findByName(context, Group.ADMIN); if(adminGroup==null) { throw new CrosswalkInternalException("The DSpace database has not been properly initialized. The Administrator Group is missing from the database."); } rp.setGroup(adminGroup); } // else if this permission pertains to another DSpace group else if(GROUP_CONTEXTCLASS.equals(contextClass)) { try { //we need to find the name of DSpace group it pertains to //Get the text within the <UserName> child element, // this is the group's name String groupName = element.getChildTextTrim("UserName", METSRights_NS); //Translate Group name back to internal ID format (e.g. COLLECTION_<ID>_ADMIN) // from its external format (e.g. COLLECTION_<handle>_ADMIN) groupName = PackageUtils.translateGroupNameForImport(context, groupName); //Check if this group exists in DSpace already Group group = groupService.findByName(context, groupName); //if not found, throw an error -- user should restore group from the SITE AIP if(group==null) { throw new CrosswalkInternalException("Cannot restore Group permissions on object (" + "type=" + Constants.typeText[dso.getType()] + ", " + "handle=" + dso.getHandle() + ", " + "ID=" + dso.getID() + "). The Group named '" + groupName + "' is missing from DSpace. " + "Please restore this group using the SITE AIP, or recreate it."); } //assign group to policy rp.setGroup(group); } catch(PackageException pe) { //A PackageException will only be thrown if translateDefaultGroupName() fails //We'll just wrap it as a CrosswalkException and throw it upwards throw new CrosswalkException(pe); } }// else if this permission pertains to a DSpace person else if(PERSON_CONTEXTCLASS.equals(contextClass)) { //we need to find the person it pertains to // Get the text within the <UserName> child element, // this is the person's email address String personEmail = element.getChildTextTrim("UserName", METSRights_NS); //Check if this person exists in DSpace already EPerson person = ePersonService.findByEmail(context, personEmail); //If cannot find by email, try by netID //(though METSRights should contain email if it was exported by DSpace) if(person==null) { person = ePersonService.findByNetid(context, personEmail); } //if not found, throw an error -- user should restore person from the SITE AIP if(person==null) { throw new CrosswalkInternalException("Cannot restore Person permissions on object (" + "type=" + Constants.typeText[dso.getType()] + ", " + "handle=" + dso.getHandle() + ", " + "ID=" + dso.getID() + "). The Person with email/netid '" + personEmail + "' is missing from DSpace. " + "Please restore this Person object using the SITE AIP, or recreate it."); } //assign person to the policy rp.setEPerson(person); }//end if Person else { log.error("Unrecognized CONTEXTCLASS: " + contextClass); } //set permissions on policy add to list of policies rp.setAction(parsePermissions(permsElement)); policies.add(rp); } //end if "Context" element }//end for loop // Finally, we need to remove any existing policies from the current object, // and replace them with the policies provided via METSRights. NOTE: // if the list of policies provided by METSRights is an empty list, then // the final object will have no policies attached. authorizeService.removeAllPolicies(context, dso); authorizeService.addPolicies(context, policies, dso); } // end else } /** * Parses the 'permsElement' (corresponding to a <code>Permissions</code> * element) to find the corresponding DSpace permission type. This * DSpace permission type must be one of the Action IDs specified in * <code>org.dspace.core.Constants</code> * <P> * Returns -1 if failed to parse permissions. * * @param permsElement The METSRights <code>Permissions</code> element * @return A DSpace Action ID from <code>org.dspace.core.Constants</code> */ private int parsePermissions(Element permsElement) { //First, check if the @OTHERPERMITTYPE attribute is specified String otherPermitType = permsElement.getAttributeValue("OTHERPERMITTYPE"); //if @OTHERPERMITTYPE attribute exists, it will map directly to a DSpace Action type if(otherPermitType!=null && !otherPermitType.isEmpty()) { if(otherTypesMapping.containsValue(otherPermitType)) { //find the Action ID this value maps to for(int actionType: otherTypesMapping.keySet()) { //if found, this is the Action ID corresponding to this permission if(otherTypesMapping.get(actionType).equals(otherPermitType)) { return actionType; } } } else { log.warn("Unrecognized @OTHERPERMITTYPE attribute value (" + otherPermitType + ") found in METSRights section of METS Manifest."); } } else // Otherwise, a closer analysis of all Permission element attributes is necessary { boolean discoverPermit = Boolean.parseBoolean(permsElement.getAttributeValue("DISCOVER")); boolean displayPermit = Boolean.parseBoolean(permsElement.getAttributeValue("DISPLAY")); boolean modifyPermit = Boolean.parseBoolean(permsElement.getAttributeValue("MODIFY")); boolean deletePermit = Boolean.parseBoolean(permsElement.getAttributeValue("DELETE")); boolean otherPermit = Boolean.parseBoolean(permsElement.getAttributeValue("OTHER")); //if DELETE='true' if(deletePermit && !otherPermit) { //This must refer to the DELETE action type //(note REMOVE & ADMIN action type have @OTHERPERMITTYPE values specified) return Constants.DELETE; }//if MODIFY='true' else if(modifyPermit && !otherPermit) { //This must refer to the WRITE action type //(note ADD action type has an @OTHERPERMITTYPE value specified) return Constants.WRITE; } else if(discoverPermit && displayPermit && !otherPermit) { //This must refer to the READ action type return Constants.READ; } } //if we got here, we failed to parse out proper permissions // return -1 to signify failure (as 0 = READ permissions) return -1; } }