/* * CDDL HEADER START * * The contents of this file are subject to the terms of the * Common Development and Distribution License, Version 1.0 only * (the "License"). You may not use this file except in compliance * with the License. * * You can obtain a copy of the license at * docs/licenses/cddl.txt * or http://www.opensource.org/licenses/cddl1.php. * See the License for the specific language governing permissions * and limitations under the License. * * When distributing Covered Code, include this CDDL HEADER in each * file and include the License file at * docs/licenses/cddl.txt. If applicable, * add the following below this CDDL HEADER, with the fields enclosed * by brackets "[]" replaced with your own identifying information: * Portions Copyright [yyyy] [name of copyright owner] * * CDDL HEADER END * * * Portions Copyright 2017-2024 Ping Identity Corporation */ package com.unboundid.directory.sdk.examples; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import com.unboundid.directory.sdk.common.schema.AttributeType; import com.unboundid.directory.sdk.common.operation.AddRequest; import com.unboundid.directory.sdk.common.operation.AddResult; import com.unboundid.directory.sdk.common.operation.ModifyRequest; import com.unboundid.directory.sdk.common.operation.ModifyResult; import com.unboundid.directory.sdk.common.operation.UpdatableAddRequest; import com.unboundid.directory.sdk.common.operation.UpdatableAddResult; import com.unboundid.directory.sdk.common.operation.UpdatableModifyRequest; import com.unboundid.directory.sdk.common.operation.UpdatableModifyResult; import com.unboundid.directory.sdk.common.types.ActiveOperationContext; import com.unboundid.directory.sdk.common.types.AlertSeverity; import com.unboundid.directory.sdk.common.types.CompletedOperationContext; import com.unboundid.directory.sdk.common.types.Entry; import com.unboundid.directory.sdk.common.types.InternalConnection; import com.unboundid.directory.sdk.common.types.OperationContext; import com.unboundid.directory.sdk.ds.api.Plugin; import com.unboundid.directory.sdk.ds.config.PluginConfig; import com.unboundid.directory.sdk.ds.types.DirectoryServerContext; import com.unboundid.directory.sdk.ds.types.PostResponsePluginResult; import com.unboundid.directory.sdk.ds.types.PreOperationPluginResult; import com.unboundid.directory.sdk.ds.types.PreParsePluginResult; import com.unboundid.ldap.sdk.Attribute; import com.unboundid.ldap.sdk.DereferencePolicy; import com.unboundid.ldap.sdk.DN; import com.unboundid.ldap.sdk.Filter; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.LDAPSearchException; import com.unboundid.ldap.sdk.Modification; import com.unboundid.ldap.sdk.ModificationType; import com.unboundid.ldap.sdk.RDN; import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.ldap.sdk.SearchRequest; import com.unboundid.ldap.sdk.SearchResult; import com.unboundid.ldap.sdk.SearchResultEntry; import com.unboundid.ldap.sdk.SearchScope; import com.unboundid.ldap.sdk.unboundidds.jsonfilter.ANDJSONObjectFilter; import com.unboundid.ldap.sdk.unboundidds.jsonfilter.EqualsAnyJSONObjectFilter; import com.unboundid.ldap.sdk.unboundidds.jsonfilter.EqualsJSONObjectFilter; import com.unboundid.ldap.sdk.unboundidds.jsonfilter.JSONObjectFilter; import com.unboundid.ldap.sdk.unboundidds.jsonfilter.NegateJSONObjectFilter; import com.unboundid.util.StaticUtils; import com.unboundid.util.args.ArgumentException; import com.unboundid.util.args.ArgumentParser; import com.unboundid.util.args.BooleanValueArgument; import com.unboundid.util.args.DNArgument; import com.unboundid.util.args.FilterArgument; import com.unboundid.util.args.StringArgument; import com.unboundid.util.json.JSONArray; import com.unboundid.util.json.JSONObject; import com.unboundid.util.json.JSONValue; /** * The JSON Field Uniqueness Plugin can be used to enforce uniqueness * constraints for values of a specified JSON field stored in a given LDAP * attribute. Uniqueness can be enforced across entries so that the same value * will not be allowed to appear in two different entries. It can also be * enforced within the same entry, so that each value of the JSON field (whether * in separate values of the LDAP attribute or within a JSON array in the same * LDAP attribute value). * <BR><BR> * This plugin can be used in either the Directory Server or the Directory Proxy * Server, although it is not recommended to be configured in both types of * servers in the same topology. In deployments in which each Directory Server * instance contains a complete copy of all the data (regardless of whether * requests to those instances pass through the Directory Proxy Server), the * plugin should be configured in all Directory Server instances as a * pre-operation and post-synchronization plugin for add and modify operations, * and it should not be configured in any Directory Proxy Server instances. For * each add and modify request received from an external client, the plugin will * check to see if that operation includes any changes involving the target JSON * field within the appropriate LDAP attribute, and will reject any change that * would introduce a conflict with a value that already exists in another entry, * or that includes duplicate values within the same entry if that is not * allowed. It will also examine each add and modify operation that is * replicated from another server, and if that operation introduced a conflict * (which may arise if two conflicting requests are processed by different * servers at the same time), the plugin will generate an alert to notify * administrators of the conflict so that it can be addressed by manual * interaction. * <BR><BR> * In deployments that use the Directory Proxy Server to separate data across * multiple servers, whether through entry balancing or by separating different * portions of the DIT across different sets of servers, the plugin should be * configured in all Directory Proxy Server instances as a pre-parse and * post-response plugin for add and modify operations, and it should not be * configured in any of the Directory Server instances. For each add and modify * request received by the Directory Proxy Server, the plugin will reject any * request that attempts to introduce a conflicting value for the target JSON * field. It will also check for conflicts after each of those operations have * completed (to ensure that no conflicts were introduced by two conflicting * requests processed by different servers at the same time), and the plugin * will generate an alert to notify administrators of each conflict identified * so that they can be addressed by manual interaction. */ public class JSONFieldUniquenessPlugin extends Plugin { /** * The parent DN for all JSON attribute constraints definitions contained in * the server configuration. */ private static final DN JSON_ATTRIBUTE_CONSTRAINTS_PARENT_DN = new DN( new RDN("cn", "JSON Attribute Constraints"), new RDN("cn", "Cluster"), new RDN("cn", "config")); /** * The set of expected plugin types for a plugin that is running in the * Directory Server. */ private final Set<String> EXPECTED_DIRECTORY_PLUGIN_TYPES = Collections.unmodifiableSet(new HashSet<String>(Arrays.asList( "preOperationAdd".toLowerCase(), "preOperationModify".toLowerCase(), "postSynchronizationAdd".toLowerCase(), "postSynchronizationModify".toLowerCase()))); /** * The set of expected plugin types for a plugin that is running in the * Directory Proxy Server. */ private final Set<String> EXPECTED_PROXY_PLUGIN_TYPES = Collections.unmodifiableSet(new HashSet<String>(Arrays.asList( "preParseAdd".toLowerCase(), "preParseModify".toLowerCase(), "postResponseAdd".toLowerCase(), "postResponseModify".toLowerCase()))); /** * The name of the required argument used to specify the name or OID of the * LDAP attribute for which uniqueness is to be maintained. */ private static final String ARG_ATTR_NAME = "ldap-attribute-name"; /** * The name of the required argument used to specify the path to the JSON * field for which uniqueness is to be maintained. */ private static final String ARG_JSON_FIELD_PATH = "json-field-path"; /** * The name of the optional argument used to indicate whether the plugin * should ensure that values of the target field must not conflict with values * for the same field across other entries within the branches indicated by * the configured base DN(s). */ private static final String ARG_REQUIRE_UNIQUENESS_ACROSS_ENTRIES = "require-uniqueness-across-entries"; /** * The name of the optional argument used to indicate whether the plugin * should ensure that multiple values of the target field within the same * entry will be required to be unique. */ private static final String ARG_REQUIRE_UNIQUENESS_WITHIN_AN_ENTRY = "require-uniqueness-within-an-entry"; /** * The name of the optional argument used to specify the base DN(s) that will * be used for searches to find conflicting entries. */ private static final String ARG_BASE_DN = "entry-base-dn"; /** * The name of the optional argument used to provide an LDAP search filter * that indicates the set of entries for which uniqueness must be enforced. */ private static final String ARG_UNIQUE_ENTRY_LDAP_FILTER = "unique-entry-ldap-filter"; /** * The name of the optional argument used to provide a JSON object filter that * a JSON object contained in a value of the specified LDAP attribute will * be required to match before applying the uniqueness constraint to it. */ private static final String ARG_UNIQUE_VALUE_JSON_OBJECT_FILTER = "unique-value-json-object-filter"; /** * The name of the attribute in JSON field constraints definitions that * indicates whether the associated JSON field is indexed. */ private static final String ATTR_JSON_FIELD_CONSTRAINTS_INDEX_VALUES = "ds-cfg-index-values"; /** * The OID for the JSONObject syntax, which is the required syntax for the * configured LDAP attribute type. */ private static final String OID_JSON_OBJECT_SYNTAX = "1.3.6.1.4.1.30221.2.3.4"; // The attribute type for which to enforce uniqueness. private volatile AttributeType ldapAttributeType; // Indicates whether to require uniqueness across entries. private volatile boolean requireUniquenessAcrossEntries; // Indicates whether to require uniqueness across multiple values in the same // entry. private volatile boolean requireUniquenessWithinAnEntry; // The server context to use during processing. private volatile DirectoryServerContext serverContext; // An LDAP filter that identifies which entries should be subject to // uniqueness constraints. private volatile Filter uniqueEntryLDAPFilter; // An connection that may be used to perform internal operations within the // server. private volatile InternalConnection internalConnection; // A JSON object filter that identifies which attribute values should be // subject to uniqueness constraints. private volatile JSONObjectFilter uniqueValueJSONObjectFilter; // The base DNs for subtrees in which to enforce uniqueness. private volatile List<DN> baseDNs; // The path to the JSON field for which to enforce uniqueness. private volatile List<String> jsonFieldPath; // The configuration for this plugin. private volatile PluginConfig config; // The string representation of the JSON field path. private volatile String jsonFieldPathString; /** * Creates a new instance of this plugin. */ public JSONFieldUniquenessPlugin() { ldapAttributeType = null; jsonFieldPath = null; jsonFieldPathString = null; requireUniquenessWithinAnEntry = true; requireUniquenessAcrossEntries = true; baseDNs = null; uniqueEntryLDAPFilter = null; uniqueValueJSONObjectFilter = null; serverContext = null; internalConnection = null; config = null; } /** * Retrieves the name for this extension. * * @return The name for this extension. */ @Override() public String getExtensionName() { return "JSON Field Uniqueness Plugin"; } /** * Retrieves a description for this extension. * * @return A description for this extension. */ @Override() public String[] getExtensionDescription() { return new String[] { "The JSON Field Uniqueness Plugin can be used to enforce uniqueness " + "constraints for values of a specified JSON field stored in a " + "given LDAP attribute. Uniqueness can be enforced across entries " + "so that the same value will not be allowed to appear in two " + "different entries. It can also be enforced within the same " + "entry, so that each value of the JSON field (whether in separate " + "values of the LDAP attribute or within a JSON array in the same " + "LDAP attribute value).", "This plugin can be used in either the Directory Server or the " + "Directory Proxy Server, although it is not recommended to be " + "configured in both types of servers in the same topology. In " + "deployments in which each Directory Server instance contains a " + "complete copy of all the data (regardless of whether requests to " + "those instances pass through the Directory Proxy Server), the " + "plugin should be configured in all Directory Server instances as " + "a pre-operation and post-synchronization plugin for add and " + "modify operations, and it should not be configured in any " + "Directory Proxy Server instances. For each add and modify " + "request received from an external client, the plugin will check " + "to see if that operation includes any changes involving the " + "target JSON field within the appropriate LDAP attribute, and " + "will reject any change that would introduce a conflict with a " + "value that already exists in another entry, or that includes " + "duplicate values within the same entry if that is not allowed. " + "It will also examine each add and modify operation that is " + "replicated from another server, and if that operation introduced " + "a conflict (which may arise if two conflicting requests are " + "processed by different servers at the same time), the plugin " + "will generate an alert to notify administrators of the conflict " + "so that it can be addressed by manual interaction.", "In deployments that use the Directory Proxy Server to separate data " + "across multiple servers, whether through entry balancing or by " + "separating different portions of the DIT across different sets " + "of servers, the plugin should be configured in all Directory " + "Proxy Server instances as a pre-parse and post-response plugin " + "for add and modify operations, and it should not be configured " + "in any of the Directory Server instances. For each add and " + "modify request received by the Directory Proxy Server, the " + "plugin will reject any request that attempts to introduce a " + "conflicting value for the target JSON field. It will also check " + "for conflicts after each of those operations have completed (to " + "ensure that no conflicts were introduced by two conflicting " + "requests processed by different servers at the same time), and " + "the plugin will generate an alert to notify administrators of " + "each conflict identified so that they can be addressed by " + "manual interaction." }; } /** * Updates the provided argument parser to include the arguments for this * extension. * * @param parser The argument parser to update. */ @Override() public void defineConfigArguments(final ArgumentParser parser) throws ArgumentException { parser.addArgument(new StringArgument(null, ARG_ATTR_NAME, true, 1, "{attribute}", "The name or OID of the LDAP attribute type whose values will be " + "examined for uniqueness conflicts. This attribute type must " + "be defined with a JSON Object syntax (OID " + "1.3.6.1.4.1.30221.2.3.4) in the schema for all Directory " + "Server and Directory Proxy Server instances in the topology. " + "All Directory Server instances must be configured with a JSON " + "object constraints definition for this attribute type. This " + "is a required argument.")); parser.addArgument(new StringArgument(null, ARG_JSON_FIELD_PATH, true, 1, "{path}", "The path to the JSON field for which uniqueness will be enforced. " + "For a top-level field, the path should simply be the name of " + "that field. For a nested field (a field contained in a JSON " + "object that is itself the value of a field in an outer JSON " + "object), the path to the field should be constructed by " + "concatenating the names of the fields in that path, in order " + "from outermost to innermost, and separating them with " + "periods. For example, if a JSON object has a top-level field " + "named \"contact-info\" whose value is a JSON object that may " + "contain an \"email-address\" field, then the json-field-path " + "value for that field would be " + "\"contact-info.email-address\". If the name of any field " + "in the path contains one or more periods, those periods " + "should be escaped by preceding them with a backslash so that " + "the only unescaped periods are those used as delimiters " + "between field names. All Directory Server instances must be " + "configured with a JSON field constraints definition for this " + "field, and that definition must have the index-values " + "property set to true. JSON field names will be treated in " + "a case-sensitive manner. This is a required argument.")); parser.addArgument(new BooleanValueArgument(null, ARG_REQUIRE_UNIQUENESS_ACROSS_ENTRIES, false, "{true|false}", "Indicates whether the plugin should check for uniqueness conflicts " + "across separate entries. If this is true, or if it is " + "omitted from the configuration, then the plugin will reject " + "any add or modify request that attempts to set one or more " + "values for the target JSON field if any of those values is " + "already in use in any other entry, and it will raise an " + "administrative alert for each conflict discovered after the " + "change has already been applied. If this is false, then the " + "plugin will not attempt to identify uniqueness conflicts in " + "separate entries. At least one of the " + "require-uniqueness-across-entries and " + "require-uniqueness-within-an-entry properties must be set to " + "true.", Boolean.TRUE)); parser.addArgument(new BooleanValueArgument(null, ARG_REQUIRE_UNIQUENESS_WITHIN_AN_ENTRY, false, "{true|false}", "Indicates whether the plugin should check for uniqueness conflicts " + "within the same entry. If this is true, or if it is omitted " + "from the configuration, then the plugin will reject any add " + "or modify request that attempts to set one or more values for " + "the target JSON field if any of those values is already in " + "use in the same entry, whether in separate value for the " + "LDAP attribute, or in another value in a JSON array. If this " + "is false, then the plugin will not attempt to identify " + "uniqueness conflicts within the same entry. At least one of " + "the require-uniqueness-across-entries and " + "require-uniqueness-within-an-entry properties must be set to " + "true.", Boolean.TRUE)); parser.addArgument(new DNArgument(null, ARG_BASE_DN, false, 0, "{dn}", "The base DN for a subtree in which the plugin will enforce " + "uniqueness constraints. This can be provided multiple times " + "to specify multiple base DNs, and if it is not specified, " + "then the plugin will use a default set of base DNs that is " + "the set of public naming contexts for the server. The " + "plugin will only examine add and modify requests that target " + "entries at or below one of these base DNs, and if uniqueness " + "is to be enforced across entries, it will ignore any " + "conflicts that may be identified outside any of the " + "configured base DNs.")); parser.addArgument(new FilterArgument(null, ARG_UNIQUE_ENTRY_LDAP_FILTER, false, 1, "{ldap-filter}", "An optional LDAP search filter that may be used to identify the " + "set of entries for which uniqueness should be enforced. If a " + "filter is configured, then the plugin will ignore any add or " + "modify requests that target entries that do not match this " + "filter, and if uniqueness is to be enforced across entries, " + "it will ignore conflicts that may be identified in entries " + "that do not match this filter.")); parser.addArgument(new StringArgument(null, ARG_UNIQUE_VALUE_JSON_OBJECT_FILTER, false, 1, "{json-object-filter}", "An optional JSON object filter that may be used to identify which " + "values of a multivalued attribute should be subject to the " + "uniqueness constraints. If a unique value JSON object " + "filter is configured, then the plugin will ignore field " + "values for JSON objects that do not match this filter.")); } /** * Initializes this plugin. * * @param serverContext A handle to the server context for the server in * which this extension is running. * @param config The general configuration for this plugin. * @param parser The argument parser which has been initialized from * the configuration for this plugin. * * @throws LDAPException If a problem occurs while initializing this plugin. */ @Override() public void initializePlugin(final DirectoryServerContext serverContext, final PluginConfig config, final ArgumentParser parser) throws LDAPException { this.serverContext = serverContext; this.config = config; // Make sure that we have an internal connection to use for processing // internal operations. if (internalConnection == null) { internalConnection = serverContext.getInternalRootConnection(); } // Use the applyConfiguration method to set up the plugin. final ArrayList<String> adminActionsRequired = new ArrayList<String>(5); final ArrayList<String> messages = new ArrayList<String>(5); final ResultCode resultCode = applyConfiguration(config, parser, adminActionsRequired, messages); if (resultCode != ResultCode.SUCCESS) { throw new LDAPException(resultCode, "One or more errors occurred while trying to initialize the JSON " + "field uniqueness plugin using the configuration contained " + "in '" + config.getConfigObjectDN() + "': " + StaticUtils.concatenateStrings(messages)); } } /** * Performs any processing which may be necessary before the server starts * processing for an add request. This will be invoked only for add * operations requested directly by clients, but not for add operations * received from another server via replication. * * @param operationContext The context for the add operation. * @param request The add request to be processed. It may be * altered if desired. * @param result The result that will be returned to the client if * the plugin result indicates that processing on * the operation should be interrupted. It may be * altered if desired. * * @return Information about the result of the plugin processing. */ @Override() public PreParsePluginResult doPreParse( final ActiveOperationContext operationContext, final UpdatableAddRequest request, final UpdatableAddResult result) { try { ensureUniqueness(request.getEntry().toLDAPSDKEntry(), serverContext, operationContext, config, internalConnection, ldapAttributeType, jsonFieldPathString, jsonFieldPath, requireUniquenessWithinAnEntry, requireUniquenessAcrossEntries, baseDNs, uniqueEntryLDAPFilter, uniqueValueJSONObjectFilter); return PreParsePluginResult.SUCCESS; } catch (final LDAPException le) { serverContext.debugCaught(le); result.setResultData(le); return new PreParsePluginResult(false, false, true, true); } } /** * Performs any processing which may be necessary before the server actually * attempts to add an entry to the appropriate backend. This will be invoked * only for add operations requested directly by clients, but not for add * operations received from another server via replication. * * @param operationContext The context for the add operation. * @param request The add request to be processed. * @param result The result that will be returned to the client if * the plugin result indicates that processing on * the operation should be interrupted. It may be * altered if desired. * * @return Information about the result of the plugin processing. */ @Override() public PreOperationPluginResult doPreOperation( final ActiveOperationContext operationContext, final AddRequest request, final UpdatableAddResult result) { try { ensureUniqueness(request.getEntry().toLDAPSDKEntry(), serverContext, operationContext, config, internalConnection, ldapAttributeType, jsonFieldPathString, jsonFieldPath, requireUniquenessWithinAnEntry, requireUniquenessAcrossEntries, baseDNs, uniqueEntryLDAPFilter, uniqueValueJSONObjectFilter); return PreOperationPluginResult.SUCCESS; } catch (final LDAPException le) { serverContext.debugCaught(le); result.setResultData(le); return new PreOperationPluginResult(false, false, true, true); } } /** * Performs any processing which may be necessary after all other processing * has been completed for an add operation and the response has been sent to * the client. This will be invoked only for add operations requested * directly by clients, but not for add operations received from another * server via replication. * * @param operationContext The context for the add operation. * @param request The add request that was processed. * @param result The result that was returned to the client. * * @return Information about the result of the plugin processing. */ @Override() public PostResponsePluginResult doPostResponse( final CompletedOperationContext operationContext, final AddRequest request, final AddResult result) { try { ensureUniqueness(request.getEntry().toLDAPSDKEntry(), serverContext, operationContext, config, internalConnection, ldapAttributeType, jsonFieldPathString, jsonFieldPath, requireUniquenessWithinAnEntry, requireUniquenessAcrossEntries, baseDNs, uniqueEntryLDAPFilter, uniqueValueJSONObjectFilter); } catch (final LDAPException le) { serverContext.debugCaught(le); // This means that a replicated operation generated a conflict. We can't // do anything to prevent this, but we can generate an administrative // alert to notify administrators of the problem. serverContext.sendAlert(AlertSeverity.ERROR, le.getMessage()); } return PostResponsePluginResult.SUCCESS; } /** * Performs any processing which may be necessary after all other processing * has been completed for an add operation which has been received from * another server via replication. * * @param operationContext The context for the add operation. * @param request The add request that was processed. * @param result The result that was returned to the client. */ @Override() public void doPostReplication( final CompletedOperationContext operationContext, final AddRequest request, final AddResult result) { try { ensureUniqueness(request.getEntry().toLDAPSDKEntry(), serverContext, operationContext, config, internalConnection, ldapAttributeType, jsonFieldPathString, jsonFieldPath, requireUniquenessWithinAnEntry, requireUniquenessAcrossEntries, baseDNs, uniqueEntryLDAPFilter, uniqueValueJSONObjectFilter); } catch (final LDAPException le) { serverContext.debugCaught(le); // This means that a replicated operation generated a conflict. We can't // do anything to prevent this, but we can generate an administrative // alert to notify administrators of the problem. serverContext.sendAlert(AlertSeverity.ERROR, le.getMessage()); } } /** * Performs any processing which may be necessary before the server starts * processing for a modify request. This will be invoked only for modify * operations requested directly by clients, but not for modify operations * received from another server via replication. * * @param operationContext The context for the modify operation. * @param request The modify request to be processed. It may be * altered if desired. * @param result The result that will be returned to the client if * the plugin result indicates that processing on * the operation should be interrupted. It may be * altered if desired. * * @return Information about the result of the plugin processing. */ @Override() public PreParsePluginResult doPreParse( final ActiveOperationContext operationContext, final UpdatableModifyRequest request, final UpdatableModifyResult result) { // We aren't given access to the entry (either before or after the changes // are applied), so we'll need to fetch it from a backend server and apply // the changes to it so we'll have a representation of the updated entry for // examination. But we'll only do that if the request could result in a // conflict, since otherwise retrieving the entry would be wasted effort. // // First, we want to make sure that we're dealing with a consistent // configuration, so get all of the configuration properties first. final AttributeType attributeType = ldapAttributeType; final List<String> fieldPath = jsonFieldPath; final String fieldPathString = jsonFieldPathString; final boolean uniqueWithinEntry = requireUniquenessWithinAnEntry; final boolean uniqueAcrossEntries = requireUniquenessAcrossEntries; final Filter uniqueEntryFilter = uniqueEntryLDAPFilter; final JSONObjectFilter uniqueValueFilter = uniqueValueJSONObjectFilter; if (! couldCreateConflict(request, attributeType, baseDNs, fieldPath)) { return PreParsePluginResult.SUCCESS; } // Retrieve the entry from a backend server. final SearchResultEntry currentEntry; try { currentEntry = internalConnection.getEntry(request.getDN(), "*", "+"); if (currentEntry == null) { result.setResultCode(ResultCode.NO_SUCH_OBJECT); result.setDiagnosticMessage("Could not find entry " + request.getDN()); return new PreParsePluginResult(false, false, true, true); } } catch (final LDAPException le) { serverContext.debugCaught(le); result.setResultData(le); return new PreParsePluginResult(false, false, true, true); } // Update the entry with the modifications from the request. final com.unboundid.ldap.sdk.Entry updatedEntry; try { updatedEntry = com.unboundid.ldap.sdk.Entry.applyModifications( currentEntry, false, request.getModifications()); } catch (final LDAPException le) { serverContext.debugCaught(le); result.setResultData(le); return new PreParsePluginResult(false, false, true, true); } try { ensureUniqueness(updatedEntry, serverContext, operationContext, config, internalConnection, attributeType, fieldPathString, fieldPath, uniqueWithinEntry, uniqueAcrossEntries, baseDNs, uniqueEntryFilter, uniqueValueFilter); return PreParsePluginResult.SUCCESS; } catch (final LDAPException le) { serverContext.debugCaught(le); result.setResultData(le); return new PreParsePluginResult(false, false, true, true); } } /** * Performs any processing which may be necessary before the server actually * attempts to update the entry in the backend. This will be invoked only for * modify operations requested directly by clients, but not for modify * operations received from another server via replication. * * @param operationContext The context for the modify operation. * @param request The modify request to be processed. * @param result The result that will be returned to the client if * the plugin result indicates that processing on * the operation should be interrupted. It may be * altered if desired. * @param oldEntry The entry as it appeared before the modifications * were applied. * @param newEntry The updated entry as it will appear after the * modifications have been applied. * * @return Information about the result of the plugin processing. */ @Override() public PreOperationPluginResult doPreOperation( final ActiveOperationContext operationContext, final ModifyRequest request, final UpdatableModifyResult result, final Entry oldEntry, final Entry newEntry) { try { ensureUniqueness(newEntry.toLDAPSDKEntry(), serverContext, operationContext, config, internalConnection, ldapAttributeType, jsonFieldPathString, jsonFieldPath, requireUniquenessWithinAnEntry, requireUniquenessAcrossEntries, baseDNs, uniqueEntryLDAPFilter, uniqueValueJSONObjectFilter); return PreOperationPluginResult.SUCCESS; } catch (final LDAPException le) { serverContext.debugCaught(le); result.setResultData(le); return new PreOperationPluginResult(false, false, true, true); } } /** * Performs any processing which may be necessary after all other processing * has been completed for a modify operation and the response has been sent * to the client. This will be invoked only for modify operations requested * directly by clients, but not for modify operations received from another * server via replication. * * @param operationContext The context for the modify operation. * @param request The modify request that was processed. * @param result The result that was returned to the client. * * @return Information about the result of the plugin processing. */ @Override() public PostResponsePluginResult doPostResponse( final CompletedOperationContext operationContext, final ModifyRequest request, final ModifyResult result) { // We aren't given access to the updated entry, so we'll need to fetch it // from a backend server. But we'll only do that if the request could // result in a conflict, since otherwise retrieving the entry would be // wasted effort. // // First, we want to make sure that we're dealing with a consistent // configuration, so get all of the configuration properties first. final AttributeType attributeType = ldapAttributeType; final List<String> fieldPath = jsonFieldPath; final String fieldPathString = jsonFieldPathString; final boolean uniqueWithinEntry = requireUniquenessWithinAnEntry; final boolean uniqueAcrossEntries = requireUniquenessAcrossEntries; final Filter uniqueEntryFilter = uniqueEntryLDAPFilter; final JSONObjectFilter uniqueValueFilter = uniqueValueJSONObjectFilter; if (! couldCreateConflict(request, attributeType, baseDNs, fieldPath)) { return PostResponsePluginResult.SUCCESS; } // Retrieve the entry. final SearchResultEntry entry; try { entry = internalConnection.getEntry(request.getDN(), "*", "+"); if (entry == null) { if (serverContext.debugEnabled()) { serverContext.debugWarning("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' could not find entry '" + request.getDN() + "' targeted by proxied modify operation conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ". This likely indicates that the entry was deleted shortly " + "after the modification. At any rate, no uniqueness conflict " + "processing can be performed for this entry."); } return PostResponsePluginResult.SUCCESS; } } catch (final Exception e) { if (serverContext.debugEnabled()) { serverContext.debugCaught(e); serverContext.debugError("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' encountered an error while trying to retrieve entry '" + request.getDN() + "' targeted by proxied modify operation " + "conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ": " + StaticUtils.getExceptionMessage(e) + ". No uniqueness conflict processing can be performed for this " + "entry."); } return PostResponsePluginResult.SUCCESS; } try { ensureUniqueness(entry, serverContext, operationContext, config, internalConnection, attributeType, fieldPathString, fieldPath, uniqueWithinEntry, uniqueAcrossEntries, baseDNs, uniqueEntryFilter, uniqueValueFilter); } catch (final LDAPException le) { serverContext.debugCaught(le); // This means that a proxied operation generated a conflict. We can't do // anything to prevent this, but we can generate an administrative alert // to notify administrators of the problem. serverContext.sendAlert(AlertSeverity.ERROR, le.getMessage()); } return PostResponsePluginResult.SUCCESS; } /** * Performs any processing which may be necessary after all other processing * has been completed for a modify operation which has been received from * another server via replication. * * @param operationContext The context for the modify operation. * @param request The modify request that was processed. * @param result The result that was returned to the client. */ @Override() public void doPostReplication( final CompletedOperationContext operationContext, final ModifyRequest request, final ModifyResult result) { // We aren't given access to the updated entry, so we'll need to fetch it // from the backend. But we'll only do that if the request could result // in a conflict, since otherwise retrieving the entry would be wasted // effort. // // First, we want to make sure that we're dealing with a consistent // configuration, so get all of the configuration properties first. final AttributeType attributeType = ldapAttributeType; final List<String> fieldPath = jsonFieldPath; final String fieldPathString = jsonFieldPathString; final boolean uniqueWithinEntry = requireUniquenessWithinAnEntry; final boolean uniqueAcrossEntries = requireUniquenessAcrossEntries; final Filter uniqueEntryFilter = uniqueEntryLDAPFilter; final JSONObjectFilter uniqueValueFilter = uniqueValueJSONObjectFilter; if (! couldCreateConflict(request, attributeType, baseDNs, fieldPath)) { return; } // Retrieve the entry. final SearchResultEntry entry; try { entry = internalConnection.getEntry(request.getDN(), "*", "+"); if (entry == null) { if (serverContext.debugEnabled()) { serverContext.debugWarning("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' could not find entry '" + request.getDN() + "' targeted by replicated modify operation conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ". This likely indicates that the entry was deleted shortly " + "after the modification. At any rate, no uniqueness conflict " + "processing can be performed for this entry."); } return; } } catch (final Exception e) { if (serverContext.debugEnabled()) { serverContext.debugCaught(e); serverContext.debugError("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' encountered an error while trying to retrieve entry '" + request.getDN() + "' targeted by replicated modify operation " + "conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ": " + StaticUtils.getExceptionMessage(e) + ". No uniqueness conflict processing can be performed for this " + "entry."); } return; } try { ensureUniqueness(entry, serverContext, operationContext, config, internalConnection, attributeType, fieldPathString, fieldPath, uniqueWithinEntry, uniqueAcrossEntries, baseDNs, uniqueEntryFilter, uniqueValueFilter); } catch (final LDAPException le) { serverContext.debugCaught(le); // This means that a replicated operation generated a conflict. We can't // do anything to prevent this, but we can generate an administrative // alert to notify administrators of the problem. serverContext.sendAlert(AlertSeverity.ERROR, le.getMessage()); } } /** * Attempts to determine whether the provided modify request could create a * uniqueness conflict. * * @param request The modify request being processed. * @param attributeType The LDAP attribute type for which uniqueness will be * enforced. * @param baseDNList The list of base DNs for which uniqueness will be * enforced. * @param fieldPath The path to the JSON field for which uniqueness will * be enforced. * * @return {@code true} if the provided modify request could create a * uniqueness conflict, or {@code false} if not. */ private boolean couldCreateConflict(final ModifyRequest request, final AttributeType attributeType, final List<DN> baseDNList, final List<String> fieldPath) { // See if the target entry is within any of the configured base DNs. If // not, then we don't care about it. try { final DN parsedEntryDN = new DN(request.getDN()); boolean withinBaseDN = false; for (final DN baseDN : baseDNList) { if (parsedEntryDN.isDescendantOf(baseDN, true)) { withinBaseDN = true; break; } } if (! withinBaseDN) { return false; } } catch (final Exception e) { serverContext.debugCaught(e); } // Now, iterate through the modifications to see if any of them could // generate a conflict. for (final Modification m : request.getModifications()) { // We only care about modification types of ADD or REPLACE. if (! ((m.getModificationType() == ModificationType.ADD) || (m.getModificationType() == ModificationType.REPLACE))) { continue; } // We only care about modifications that have values. A REPLACE // modification might not have any values if it's intended to remove // the attribute from the entry. final String[] values = m.getValues(); if ((values == null) || (values.length == 0)) { continue; } // See if the modification targets the right attribute type. // // NOTE: It's possible that the modification targeted an attribute with // options, so we need to check the base name without any options. final String baseName = Attribute.getBaseName(m.getAttributeName()); if (! attributeType.hasNameOrOID(baseName)) { continue; } // Try to parse each of the values as a JSON object and see if any of // them contains the target field. That'll be enough to convince us // that we need to get the entry. for (final String value : values) { try { final JSONObject o = new JSONObject(value); final List<JSONValue> fieldValues = getValues(o, fieldPath); if (fieldValues.isEmpty()) { continue; } return true; } catch (final Exception e) { serverContext.debugCaught(e); } } } // If we've gotten here, then the modification couldn't have introduced a // conflict. return false; } /** * Performs all necessary uniqueness processing for the provided entry. * * @param entry The entry to examine. * @param serverContext The server context. * @param operationContext The operation context. * @param config The configuration for the plugin. * @param internalConnection The internal connection to use to perform * internal searches. * @param attributeType The target attribute type. * @param fieldPathString The string representation of the JSON field * path. * @param fieldPath The components that comprise the path to the * target JSON field. * @param uniqueWithinEntry Indicates whether to enforce uniqueness across * multiple values within the same entry. * @param uniqueAcrossEntries Indicates whether to enforce uniqueness across * entries within the specified set of base DNs. * @param baseDNs The set of base DNs below which to enforce * uniqueness. * @param uniqueEntryFilter An optional filter that entries must match. * @param uniqueValueFilter An optional filter that values must match. * * @throws LDAPException If a conflict is found. */ private static void ensureUniqueness(final com.unboundid.ldap.sdk.Entry entry, final DirectoryServerContext serverContext, final OperationContext operationContext, final PluginConfig config, final InternalConnection internalConnection, final AttributeType attributeType, final String fieldPathString, final List<String> fieldPath, final boolean uniqueWithinEntry, final boolean uniqueAcrossEntries, final List<DN> baseDNs, final Filter uniqueEntryFilter, final JSONObjectFilter uniqueValueFilter) throws LDAPException { // See if the entry is below a base DN that we care about. try { final DN parsedEntryDN = entry.getParsedDN(); boolean withinBaseDN = false; for (final DN baseDN : baseDNs) { if (parsedEntryDN.isDescendantOf(baseDN, true)) { withinBaseDN = true; break; } } if (! withinBaseDN) { if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + " is skipping uniqueness processing for entry '" + entry.getDN() + "' for conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + " because it is not " + "within any of the configured base DNs " + baseDNs); } return; } } catch (final Exception e) { if (serverContext.debugEnabled()) { serverContext.debugCaught(e); serverContext.debugWarning("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' could not parse DN '" + entry.getDN() + "' encountered while processing operation conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ": " + StaticUtils.getExceptionMessage(e) + ". Going to assume that " + "the entry is within a base DN for which uniqueness should be " + "enforced."); } } // If there is a unique entry filter, then see if the entry matches it. if (uniqueEntryFilter != null) { try { if (! uniqueEntryFilter.matchesEntry(entry)) { if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + " is skipping uniqueness " + "processing for entry '" + entry.getDN() + "' for conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + " because it does not " + "match " + ARG_UNIQUE_ENTRY_LDAP_FILTER + " value " + uniqueEntryFilter); } return; } } catch (final Exception e) { if (serverContext.debugEnabled()) { serverContext.debugCaught(e); serverContext.debugWarning("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' encountered an error while evaluating " + ARG_UNIQUE_ENTRY_LDAP_FILTER + " value " + uniqueEntryFilter + " against entry '" + entry.getDN() + "' for conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ": " + StaticUtils.getExceptionMessage(e) + ". Going to assume that " + "the entry matches the filter."); } } } // Iterate through the attributes in the entry to get a list of all values // for the target field from the target attribute type. final ArrayList<JSONValue> fieldValues = new ArrayList<JSONValue>(10); for (final Attribute a : entry.getAttributes()) { // See if the attribute has the appropriate attribute type. // // NOTE: The attribute could have options, so make sure to check the // base name. if (! attributeType.hasNameOrOID(a.getBaseName())) { continue; } // Iterate through the values of the attribute and try to parse them as // JSON objects. for (final String value : a.getValues()) { final JSONObject o; try { o = new JSONObject(value); } catch (final Exception e) { if (serverContext.debugEnabled()) { serverContext.debugCaught(e); serverContext.debugWarning("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' encountered " + a.getName() + " value '" + value + "' in entry '" + entry.getDN() + "' that could not be parsed as a valid JSON object for conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + ": " + StaticUtils.getExceptionMessage(e)); } continue; } // If we have a uniqueValueFilter, then see if the provided object // matches that filter. If not, then skip this value. if ((uniqueValueFilter != null) && (! uniqueValueFilter.matchesJSONObject(o))) { continue; } // Extract the values of the target field from the object. Iterate // through them and see if any of them match a value that we've already // found. At the very least, we only want to do the processing for each // value once, but we might also want to reject conflicts in the same // entry. for (final JSONValue jsonValue : getValues(o, fieldPath)) { boolean conflictFound = false; for (final JSONValue v : fieldValues) { if (v.equals(jsonValue, false, true, false)) { conflictFound = true; break; } } if (conflictFound) { if (uniqueWithinEntry) { throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "Entry '" + entry.getDN() + "' has duplicate values for " + "JSON field " + fieldPathString + " but the server is configured to require all values " + "for that field to be unique, even within the same " + "entry."); } } else { fieldValues.add(jsonValue); } } } } // If we shouldn't check for conflicts across other entries, or if we didn't // find any field values, then we're done. if (fieldValues.isEmpty()) { if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' did not find any " + attributeType.getNameOrOID() + " values for field " + fieldPathString + " in entry '" + entry.getDN() + "' for conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID()); } return; } if (! uniqueAcrossEntries) { if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' is only configured to look for duplicate " + fieldPathString + " values in attribute " + attributeType.getNames() + " within the same entry, and entry '" + entry.getDN() + "' for conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + " did not have any such conflicts."); } return; } if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin defined in " + "configuration entry '" + config.getConfigObjectDN() + "' found one or more values for JSON field " + fieldPathString + " in attribute " + attributeType.getNameOrOID() + " in entry '" + entry.getDN() + " for operation conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID() + "': " + fieldValues + ". Going to search for entries with conflicting values."); } // We need to look for conflicts across other entries. First, generate the // equals or equalsAny filter to use to identify entries with conflicting // values. JSONObjectFilter jsonObjectFilter; if (fieldValues.size() == 1) { jsonObjectFilter = new EqualsJSONObjectFilter(fieldPath, fieldValues.get(0)); } else { jsonObjectFilter = new EqualsAnyJSONObjectFilter(fieldPath, fieldValues); } // If we have a unique value filter, then we'll need to AND the JSON object // filter we just created with a NOT of that value filter so that we exclude // objects that don't match that filter. if (uniqueValueFilter != null) { jsonObjectFilter = new ANDJSONObjectFilter(jsonObjectFilter, new NegateJSONObjectFilter(uniqueValueFilter)); } // Get an LDAP filter that will allow us to process the JSON object filter. Filter ldapFilter = jsonObjectFilter.toLDAPFilter(attributeType.getNameOrOID()); // If we have a unique entry filter, then we'll need to AND the LDAP filter // we just created with that unique entry filter so that we only check // entries that match that filter. if (uniqueEntryFilter != null) { ldapFilter = Filter.createANDFilter(ldapFilter, uniqueEntryFilter); } // Iterate through the configured base DNs and search beneath them for // conflicting entries. // // NOTE: Use a size limit of 1 for the search request so that we don't // waste time finding all the matches once we know that there are at least // two matching entries. One matching entry may be okay if it's the entry // that we're currently validating, and we should only report a conflict for // one match if it doesn't match the DN of the entry we're currently // validating. But any more than one match (which should trigger a // SIZE_LIMIT_EXCEEDED result) means that there's definitely a conflict. final SearchRequest searchRequest = new SearchRequest("", SearchScope.SUB, DereferencePolicy.NEVER, 1, 0, false, ldapFilter, "1.1"); for (final DN baseDN : baseDNs) { searchRequest.setBaseDN(baseDN); SearchResult searchResult; try { searchResult = internalConnection.search(searchRequest); } catch (final LDAPSearchException lse) { searchResult = lse.getSearchResult(); } if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin " + "defined in configuration entry '" + config.getConfigObjectDN() + "' got search result " + searchResult + " for internal search " + searchRequest); } switch (searchResult.getResultCode().intValue()) { case ResultCode.SUCCESS_INT_VALUE: // The search completed successfully, but we need to check the entries // that were returned to see if it matched any entry other than the // target entry. switch (searchResult.getEntryCount()) { case 0: break; case 1: final DN parsedThisDN = entry.getParsedDN(); final DN parsedFoundDN = searchResult.getSearchEntries().get(0).getParsedDN(); if (! parsedThisDN.equals(parsedFoundDN)) { throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "Entry '" + entry.getDN() + "' has one or more values " + "for JSON field '" + fieldPathString + "' that " + "already exist in at least one other entry in " + "the server, but the server is configured to " + "require all values for that field to be unique."); } break; default: throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "Entry '" + entry.getDN() + "' has one or more values for " + "JSON field '" + fieldPathString + "' that already exist in at least one other entry in " + "the server, but the server is configured to " + "require all values for that field to be unique."); } break; case ResultCode.NO_SUCH_OBJECT_INT_VALUE: // This means that the search base didn't exist. This definitely // means no conflict. break; case ResultCode.SIZE_LIMIT_EXCEEDED_INT_VALUE: // This means that there was definitely a conflict. throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "Entry '" + entry.getDN() + "' has one or more values for " + "JSON field '" + fieldPathString + "' that already exist in at least one other entry in " + "the server, but the server is configured to " + "require all values for that field to be unique."); default: // This indicates that some other error occurred that prevented the // search from succeeding. We'll treat this like a conflict in that // we'll throw an exception to reject the operation (or alert if it's // a replicated operation that's already been applied somewhere else) // but use a different message for the exception. throw new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "An error occurred while looking for uniqueness conflicts " + "for values of the '" + fieldPathString + "' field in attribute " + attributeType.getNameOrOID() + " (result code " + searchResult.getResultCode() + ", diagnostic message '" + searchResult.getDiagnosticMessage() + "'."); } } // If we've gotten here, then there are no conflicts. if (serverContext.debugEnabled()) { serverContext.debugInfo("The JSON field uniqueness plugin defined in " + "configuration entry '" + config.getConfigObjectDN() + "' did not find any conflicts for values of JSON field " + fieldPathString + " in attribute " + attributeType.getNameOrOID() + " in entry '" + entry.getDN() + " for operation conn=" + operationContext.getConnectionID() + " op=" + operationContext.getOperationID()); } } /** * Retrieves a list of the values for the specified field from the provided * object. * * @param o The JSON object to examine. * @param fieldPath The path to the JSON field for which to retrieve the * values. * * @return The set of values that match the provided field name specifier, or * an empty list if the provided JSON object does not have any fields * matching the provided specifier. */ private static List<JSONValue> getValues(final JSONObject o, final List<String> fieldPath) { final ArrayList<JSONValue> values = new ArrayList<JSONValue>(10); getValues(o, fieldPath, 0, values); return values; } /** * Adds all values for the specified field to the provided list. * * @param o The JSON object to examine. * @param fieldPath The path to the JSON field for which to retrieve * the values. * @param fieldNameIndex The current index into the field name specifier. * @param values The list into which matching values should be * added. */ private static void getValues(final JSONObject o, final List<String> fieldPath, final int fieldNameIndex, final List<JSONValue> values) { final JSONValue v = o.getField(fieldPath.get(fieldNameIndex)); if (v == null) { return; } final int nextIndex = fieldNameIndex + 1; if (nextIndex < fieldPath.size()) { // This indicates that there are more elements in the field name // specifier. The value must either be a JSON object that we can look // further into, or it must be an array containing one or more JSON // objects. if (v instanceof JSONObject) { getValues((JSONObject) v, fieldPath, nextIndex, values); } else if (v instanceof JSONArray) { getValuesFromArray((JSONArray) v, fieldPath, nextIndex, values); } return; } // If we've gotten here, then there is no more of the field specifier, so // the value we retrieved matches the specifier. Add it to the list of // values. values.add(v); } /** * Calls {@code getValues} for any elements of the provided array that are * JSON objects, recursively descending into any nested arrays. * * @param a The array to process. * @param fieldPath The path to the JSON field for which to retrieve * the values. * @param fieldNameIndex The current index into the field name specifier. * @param values The list into which matching values should be * added. */ private static void getValuesFromArray(final JSONArray a, final List<String> fieldPath, final int fieldNameIndex, final List<JSONValue> values) { for (final JSONValue v : a.getValues()) { if (v instanceof JSONObject) { getValues((JSONObject) v, fieldPath, fieldNameIndex, values); } else if (v instanceof JSONArray) { getValuesFromArray((JSONArray) v, fieldPath, fieldNameIndex, values); } } } /** * Indicates whether the configuration represented by the provided argument * parser is acceptable for use by this extension. The parser will have been * used to parse any configuration available for this extension, and any * automatic validation will have been performed. This method may be used to * perform any more complex validation which cannot be performed automatically * by the argument parser. * * @param config The general configuration for this extension. * @param parser The argument parser that has been used to * parse the proposed configuration for this * extension. * @param unacceptableReasons A list to which messages may be added to * provide additional information about why the * provided configuration is not acceptable. * * @return {@code true} if the configuration in the provided argument parser * appears to be acceptable, or {@code false} if not. */ @Override() public boolean isConfigurationAcceptable(final PluginConfig config, final ArgumentParser parser, final List<String> unacceptableReasons) { // If the server context is null, then get an instance from the provided // configuration. if (serverContext == null) { serverContext = config.getServerContext(); } // Get an internal connection to use for processing internal operations. if (internalConnection == null) { internalConnection = serverContext.getInternalRootConnection(); } // Make sure that the plugin is configured with the appropriate set of // plugin types based on the type of product in which it is running. boolean acceptable = true; final boolean isDirectoryServer; final Set<String> pluginTypes = config.getPluginTypes(); if (serverContext.isDirectoryFunctionalityAvailable()) { isDirectoryServer = true; if (! pluginTypes.equals(EXPECTED_DIRECTORY_PLUGIN_TYPES)) { acceptable = false; unacceptableReasons.add("When the " + getExtensionName() + " is used in the Directory Server, it must be configured with " + "the following plugin types: " + EXPECTED_DIRECTORY_PLUGIN_TYPES + '.'); } } else if (serverContext.isDirectoryProxyFunctionalityAvailable()) { isDirectoryServer = false; if (! pluginTypes.equals(EXPECTED_PROXY_PLUGIN_TYPES)) { acceptable = false; unacceptableReasons.add("When the " + getExtensionName() + " is used in the Directory Proxy Server, it must be configured " + "with the following plugin types: " + EXPECTED_PROXY_PLUGIN_TYPES + '.'); } } else { isDirectoryServer = false; acceptable = false; unacceptableReasons.add("The " + getExtensionName() + " can only be used in the Directory Server or Directory Proxy " + "Server."); } // Get the configured LDAP attribute type. It must be defined in the server // schema. final AttributeType attrType; final StringArgument attrNameArg = parser.getStringArgument(ARG_ATTR_NAME); if ((attrNameArg == null) || (! attrNameArg.isPresent())) { // The argument parser should ensure that this never happens. acceptable = false; unacceptableReasons.add("No value was provided for the required " + ARG_ATTR_NAME + " argument."); attrType = null; } else { attrType = serverContext.getSchema().getAttributeType( attrNameArg.getValue(), false); if (attrType == null) { acceptable = false; unacceptableReasons.add("Value '" + attrNameArg.getValue() + "' for argument " + ARG_ATTR_NAME + " does not represent the " + "name or OID for any attribute type defined in the server " + "schema."); } } DN jsonAttributeConstraintsDN = null; if (attrType != null) { // The attribute type must have a JSONObject syntax. if ((attrType.getSyntax() == null) || (! attrType.getSyntax().getOID().equals(OID_JSON_OBJECT_SYNTAX))) { acceptable = false; unacceptableReasons.add("Attribute type " + attrNameArg.getValue() + " is not defined with a JSONObject syntax (OID " + OID_JSON_OBJECT_SYNTAX + ")."); } // If the plugin is being run in the Directory Server, then make sure that // the server is configured with a JSON attribute constraints definition // for the specified attribute type. Note that it's possible that the // definition was created with an alternate name for the attribute type, // so we'll see if the entry exists with any of the names (or OID) for // that attribute type. if (isDirectoryServer) { for (final String name : getNamesAndOID(attrType)) { final DN dn = new DN( new RDN("ds-cfg-attribute-type", name), JSON_ATTRIBUTE_CONSTRAINTS_PARENT_DN); try { if (internalConnection.getEntry(dn.toString(), "1.1") != null) { jsonAttributeConstraintsDN = dn; break; } } catch (final Exception e) { serverContext.debugCaught(e); acceptable = false; unacceptableReasons.add("An error occurred while attempting to " + "determine whether a JSON attribute constraints definition " + "exists in entry '" + dn + "': " + StaticUtils.getExceptionMessage(e)); break; } } if (jsonAttributeConstraintsDN == null) { acceptable = false; unacceptableReasons.add("The server is not configured with a JSON " + "attribute constraints definition for attribute " + attrNameArg.getValue() + '.'); } } } // Retrieve and validate the configured JSON field path. final String fieldPathStr; final StringArgument fieldPathArg = parser.getStringArgument(ARG_JSON_FIELD_PATH); if ((fieldPathArg == null) || (! fieldPathArg.isPresent())) { // The argument parser should ensure that this never happens. acceptable = false; unacceptableReasons.add("No value was provided for the required " + ARG_JSON_FIELD_PATH + " argument."); fieldPathStr = null; } else { fieldPathStr = fieldPathArg.getValue(); final List<String> fieldPath = parseFieldPath(fieldPathStr, unacceptableReasons); if (fieldPath == null) { // The unacceptable reasons will have already been updated by the // parseFieldPath method. acceptable = false; } } // If the plugin is being run in the Directory Server, then make sure that // the server is configured with a JSON field constraints definition for the // target field, and that it has a ds-cfg-index-values attribute with a // value of true. if (acceptable && isDirectoryServer) { final DN jsonFieldConstraintParentDN = new DN( new RDN("cn", "JSON Field Constraints"), jsonAttributeConstraintsDN); final DN jsonFieldConstraintsDN = new DN( new RDN("ds-cfg-json-field", fieldPathStr), jsonFieldConstraintParentDN); try { final SearchResultEntry jsonFieldConstraintsEntry = internalConnection.getEntry(jsonFieldConstraintsDN.toString(), ATTR_JSON_FIELD_CONSTRAINTS_INDEX_VALUES); if (jsonFieldConstraintsEntry == null) { acceptable = false; unacceptableReasons.add("The server is not configured with a JSON " + "field constraints definition for field " + fieldPathStr + " for attribute " + attrNameArg.getValue() + '.'); } else if (! jsonFieldConstraintsEntry.hasAttributeValue( ATTR_JSON_FIELD_CONSTRAINTS_INDEX_VALUES, "true")) { acceptable = false; unacceptableReasons.add("The JSON field constraints definition in " + "entry '" + jsonFieldConstraintsDN + "' does not have a " + ATTR_JSON_FIELD_CONSTRAINTS_INDEX_VALUES + " value of true."); } } catch (final Exception e) { serverContext.debugCaught(e); acceptable = false; unacceptableReasons.add("An error occurred while attempting to " + "retrieve the JSON field constraints definition contained in " + "entry '" + jsonFieldConstraintsDN + "': " + StaticUtils.getExceptionMessage(e)); } } // If a unique value JSON object filter was provided, then make sure that // it's a valid JSON object filter. final StringArgument jsonFilterArg = parser.getStringArgument(ARG_UNIQUE_VALUE_JSON_OBJECT_FILTER); if ((jsonFilterArg != null) && jsonFilterArg.isPresent()) { final JSONObjectFilter f = parseJSONObjectFilter(jsonFilterArg.getValue(), unacceptableReasons, serverContext); if (f == null) { // The unacceptable reasons will have already been updated by the // parseJSONObjectFilter method. acceptable = false; } } // At least one of the require-uniqueness-within-an-entry and // require-uniqueness-across-entries properties must have a value of true. final BooleanValueArgument uniqueWithEntryArg = parser.getBooleanValueArgument(ARG_REQUIRE_UNIQUENESS_WITHIN_AN_ENTRY); if ((uniqueWithEntryArg != null) && uniqueWithEntryArg.isPresent() && (uniqueWithEntryArg.getValue() == Boolean.FALSE)) { final BooleanValueArgument uniqueAcrossEntriesArg = parser.getBooleanValueArgument( ARG_REQUIRE_UNIQUENESS_ACROSS_ENTRIES); if ((uniqueAcrossEntriesArg != null) && uniqueAcrossEntriesArg.isPresent() && (uniqueAcrossEntriesArg.getValue() == Boolean.FALSE)) { acceptable = false; unacceptableReasons.add("The configuration cannot have both " + ARG_REQUIRE_UNIQUENESS_WITHIN_AN_ENTRY + " and " + ARG_REQUIRE_UNIQUENESS_ACROSS_ENTRIES + " arguments set to false."); } } return acceptable; } /** * Attempts to apply the configuration from the provided argument parser to * this extension. * * @param config The general configuration for this extension. * @param parser The argument parser that has been used to * parse the new configuration for this * extension. * @param adminActionsRequired A list to which messages may be added to * provide additional information about any * additional administrative actions that may * be required to apply some of the * configuration changes. * @param messages A list to which messages may be added to * provide additional information about the * processing performed by this method. * * @return A result code providing information about the result of applying * the configuration change. A result of {@code SUCCESS} should be * used to indicate that all processing completed successfully. Any * other result will indicate that a problem occurred during * processing. */ @Override() public ResultCode applyConfiguration(final PluginConfig config, final ArgumentParser parser, final List<String> adminActionsRequired, final List<String> messages) { // If the server context is null, then get an instance from the provided // configuration. if (serverContext == null) { serverContext = config.getServerContext(); } // Get an internal connection to use for processing internal operations. if (internalConnection == null) { internalConnection = serverContext.getInternalRootConnection(); } // Get the configured LDAP attribute type. final StringArgument attrNameArg = parser.getStringArgument(ARG_ATTR_NAME); final AttributeType attrType = serverContext.getSchema().getAttributeType( attrNameArg.getValue(), false); boolean valid = true; if (attrType == null) { valid = false; messages.add("Unable to retrieve attribute type " + attrNameArg.getValue() + " from the server schema."); } // Get the configured JSON field path. final StringArgument fieldPathArg = parser.getStringArgument(ARG_JSON_FIELD_PATH); final String fieldPathString = fieldPathArg.getValue(); final List<String> fieldPath = parseFieldPath(fieldPathString, messages); if (fieldPath == null) { valid = false; } // Determine whether to require uniqueness within an entry. boolean uniqueWithinEntry = true; final BooleanValueArgument uniqueWithinEntryArg = parser.getBooleanValueArgument(ARG_REQUIRE_UNIQUENESS_WITHIN_AN_ENTRY); if (uniqueWithinEntryArg != null) { uniqueWithinEntry = uniqueWithinEntryArg.getValue(); } // Determine whether to require uniqueness across entries. boolean uniqueAcrossEntries = true; final BooleanValueArgument uniqueAcrossEntriesArg = parser.getBooleanValueArgument(ARG_REQUIRE_UNIQUENESS_ACROSS_ENTRIES); if (uniqueAcrossEntriesArg != null) { uniqueAcrossEntries = uniqueAcrossEntriesArg.getValue(); } // Get the set of base DNs. If no values are provided in the configuration, // then get the set of naming contexts from the root DSE. List<DN> baseDNList = null; final DNArgument baseDNArg = parser.getDNArgument(ARG_BASE_DN); if ((baseDNArg != null) && baseDNArg.isPresent()) { baseDNList = baseDNArg.getValues(); } if ((baseDNList == null) || baseDNList.isEmpty()) { try { final String[] namingContextDNs = internalConnection.getRootDSE().getNamingContextDNs(); final ArrayList<DN> dnList = new ArrayList<DN>(namingContextDNs.length); for (final String dn : namingContextDNs) { dnList.add(new DN(dn)); } baseDNList = Collections.unmodifiableList(dnList); } catch (final Exception e) { serverContext.debugCaught(e); } if ((baseDNList == null) || baseDNList.isEmpty()) { valid = false; messages.add("No base DNs are configured, and the public naming " + "context DNs could not be obtained from the server root DSE."); } } // Get the unique entry filter. Filter entryFilter = null; final FilterArgument filterArg = parser.getFilterArgument(ARG_UNIQUE_ENTRY_LDAP_FILTER); if ((filterArg != null) && filterArg.isPresent()) { entryFilter = filterArg.getValue(); } // Get the unique object filter. JSONObjectFilter objectFilter = null; final StringArgument objectFilterArg = parser.getStringArgument(ARG_UNIQUE_VALUE_JSON_OBJECT_FILTER); if ((objectFilterArg != null) && objectFilterArg.isPresent()) { objectFilter = parseJSONObjectFilter(objectFilterArg.getValue(), messages, serverContext); if (objectFilter == null) { valid = false; } } // If the configuration is valid, then apply it. if (valid) { ldapAttributeType = attrType; jsonFieldPathString = fieldPathString; jsonFieldPath = fieldPath; requireUniquenessWithinAnEntry = uniqueWithinEntry; requireUniquenessAcrossEntries = uniqueAcrossEntries; baseDNs = baseDNList; uniqueEntryLDAPFilter = entryFilter; uniqueValueJSONObjectFilter = objectFilter; this.config = config; return ResultCode.SUCCESS; } else { return ResultCode.OTHER; } } /** * Retrieves a list containing all of the names and the OID for the provided * attribute type. * * @param attrType The attribute type for which to obtain the names and OID. * * @return A list containing all of the names and the OID for the provided * attribute type. */ private static List<String> getNamesAndOID(final AttributeType attrType) { final ArrayList<String> namesAndOID = new ArrayList<String>(5); namesAndOID.addAll(attrType.getNames()); namesAndOID.add(attrType.getOID()); return namesAndOID; } /** * Parses the provided string to extract the path to the target field as a * list. Levels of hierarchy will be separated by periods. Any periods * contained in field names must be escaped with backslashes. * * @param fieldPathStr The string representation of the field path. * @param unacceptableReasons A list that should be updated to include any * reason that the provided string is not a valid * path. * * @return The list containing the path to the field name, or {@code null} if * the provided field path string does not represent a valid path. */ private static List<String> parseFieldPath(final String fieldPathStr, final List<String> unacceptableReasons) { boolean escaped = false; final ArrayList<String> fieldList = new ArrayList<String>(5); final StringBuilder buffer = new StringBuilder(fieldPathStr.length()); for (final char c : fieldPathStr.toCharArray()) { if (escaped) { buffer.append(c); escaped = false; } else if (c == '\\') { escaped = true; } else if (c == '.') { if (buffer.length() > 0) { fieldList.add(buffer.toString()); buffer.setLength(0); } else { unacceptableReasons.add("Value '" + fieldPathStr + "' is not a " + "valid JSON field path because it includes an empty " + "field name component."); return null; } } else { buffer.append(c); } } if (buffer.length() == 0) { unacceptableReasons.add("Value '" + fieldPathStr + "' is not a valid " + "JSON field path because it includes an empty field name " + "component."); return null; } else { fieldList.add(buffer.toString()); } return Collections.unmodifiableList(fieldList); } /** * Parses the provided string as a JSON object filter. * * @param filterStr The string to be parsed as a JSON object * filter. * @param unacceptableReasons A list that should be updated to include any * reason that the provided string is not a valid * JSON object filter. * @param serverContext The server context to use for debugging * purposes. * * @return The parsed JSON object filter, or {@code null} if the provided * string does not represent a valid JSON object filter. */ private static JSONObjectFilter parseJSONObjectFilter(final String filterStr, final List<String> unacceptableReasons, final DirectoryServerContext serverContext) { final JSONObject filterObject; try { filterObject = new JSONObject(filterStr); } catch (final Exception e) { serverContext.debugCaught(e); unacceptableReasons.add(ARG_UNIQUE_VALUE_JSON_OBJECT_FILTER + " value '" + filterStr + "' is not a valid JSON object: " + StaticUtils.getExceptionMessage(e)); return null; } try { return JSONObjectFilter.decode(filterObject); } catch (final Exception e) { serverContext.debugCaught(e); unacceptableReasons.add(ARG_UNIQUE_VALUE_JSON_OBJECT_FILTER + " value '" + filterStr + "' does not represent a valid JSON " + "object filter: " + StaticUtils.getExceptionMessage(e)); return null; } } /** * Retrieves a map containing examples of configurations that may be used for * this extension. The map key should be a list of sample arguments, and the * corresponding value should be a description of the behavior that will be * exhibited by the extension when used with that configuration. * * @return A map containing examples of configurations that may be used for * this extension. It may be {@code null} or empty if there should * not be any example argument sets. */ @Override() public Map<List<String>,String> getExamplesArgumentSets() { final List<String> exampleArgs = Arrays.asList( ARG_ATTR_NAME + "=my-json-attribute", ARG_JSON_FIELD_PATH + "=my-json-field", ARG_REQUIRE_UNIQUENESS_WITHIN_AN_ENTRY + "=true", ARG_REQUIRE_UNIQUENESS_ACROSS_ENTRIES + "=true", ARG_BASE_DN + "=dc=example,dc=com"); final String exampleDescription = "Configure the " + getExtensionName() + " to ensure that all values of the 'my-json-field' field in the " + "'my-json-attribute' attribute are unique across all entries below " + "'dc=example,dc=com', including across multiple values within the " + "same entry."; return Collections.singletonMap(exampleArgs, exampleDescription); } }