001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2008 Sun Microsystems, Inc.
015 * Portions Copyright 2014-2016 ForgeRock AS.
016 */
017package org.opends.server.extensions;
018
019import static org.opends.messages.ExtensionMessages.*;
020import static org.opends.server.protocols.internal.InternalClientConnection.*;
021import static org.opends.server.protocols.internal.Requests.*;
022import static org.opends.server.util.CollectionUtils.*;
023
024import java.util.ArrayList;
025import java.util.Collection;
026import java.util.Iterator;
027import java.util.LinkedHashSet;
028import java.util.LinkedList;
029import java.util.List;
030import java.util.Set;
031import java.util.regex.Matcher;
032import java.util.regex.Pattern;
033import java.util.regex.PatternSyntaxException;
034
035import org.forgerock.i18n.LocalizableMessage;
036import org.forgerock.opendj.config.server.ConfigChangeResult;
037import org.forgerock.opendj.config.server.ConfigException;
038import org.forgerock.opendj.ldap.ByteString;
039import org.forgerock.opendj.ldap.DN;
040import org.forgerock.opendj.ldap.ResultCode;
041import org.forgerock.opendj.ldap.SearchScope;
042import org.forgerock.opendj.config.server.ConfigurationChangeListener;
043import org.forgerock.opendj.server.config.server.IdentityMapperCfg;
044import org.forgerock.opendj.server.config.server.RegularExpressionIdentityMapperCfg;
045import org.opends.server.api.Backend;
046import org.opends.server.api.IdentityMapper;
047import org.opends.server.core.DirectoryServer;
048import org.opends.server.protocols.internal.InternalClientConnection;
049import org.opends.server.protocols.internal.InternalSearchOperation;
050import org.opends.server.protocols.internal.SearchRequest;
051import org.forgerock.opendj.ldap.schema.AttributeType;
052import org.opends.server.types.*;
053
054/**
055 * This class provides an implementation of a Directory Server identity mapper
056 * that uses a regular expression to process the provided ID string, and then
057 * looks for that processed value to appear in an attribute of a user's entry.
058 * This mapper may be configured to look in one or more attributes using zero or
059 * more search bases.  In order for the mapping to be established properly,
060 * exactly one entry must have an attribute that exactly matches (according to
061 * the equality matching rule associated with that attribute) the processed ID
062 * value.
063 */
064public class RegularExpressionIdentityMapper
065       extends IdentityMapper<RegularExpressionIdentityMapperCfg>
066       implements ConfigurationChangeListener<
067                       RegularExpressionIdentityMapperCfg>
068{
069  /** The set of attribute types to use when performing lookups. */
070  private AttributeType[] attributeTypes;
071
072  /** The DN of the configuration entry for this identity mapper. */
073  private DN configEntryDN;
074
075  /** The set of attributes to return in search result entries. */
076  private LinkedHashSet<String> requestedAttributes;
077
078  /** The regular expression pattern matcher for the current configuration. */
079  private Pattern matchPattern;
080
081  /** The current configuration for this identity mapper. */
082  private RegularExpressionIdentityMapperCfg currentConfig;
083
084  /** The replacement string to use for the pattern. */
085  private String replacePattern;
086
087  /**
088   * Creates a new instance of this regular expression identity mapper.  All
089   * initialization should be performed in the {@code initializeIdentityMapper}
090   * method.
091   */
092  public RegularExpressionIdentityMapper()
093  {
094    super();
095
096    // Don't do any initialization here.
097  }
098
099  @Override
100  public void initializeIdentityMapper(
101                   RegularExpressionIdentityMapperCfg configuration)
102         throws ConfigException, InitializationException
103  {
104    configuration.addRegularExpressionChangeListener(this);
105
106    currentConfig = configuration;
107    configEntryDN = currentConfig.dn();
108
109    try
110    {
111      matchPattern  = Pattern.compile(currentConfig.getMatchPattern());
112    }
113    catch (PatternSyntaxException pse) {
114      LocalizableMessage message = ERR_REGEXMAP_INVALID_MATCH_PATTERN.get(
115              currentConfig.getMatchPattern(),
116              pse.getMessage());
117      throw new ConfigException(message, pse);
118    }
119
120    replacePattern = currentConfig.getReplacePattern();
121    if (replacePattern == null)
122    {
123      replacePattern = "";
124    }
125
126    // Get the attribute types to use for the searches.  Ensure that they are
127    // all indexed for equality.
128    attributeTypes =
129         currentConfig.getMatchAttribute().toArray(new AttributeType[0]);
130
131    Set<DN> cfgBaseDNs = configuration.getMatchBaseDN();
132    if (cfgBaseDNs == null || cfgBaseDNs.isEmpty())
133    {
134      cfgBaseDNs = DirectoryServer.getPublicNamingContexts().keySet();
135    }
136
137    for (AttributeType t : attributeTypes)
138    {
139      for (DN baseDN : cfgBaseDNs)
140      {
141        Backend b = DirectoryServer.getBackend(baseDN);
142        if (b != null && ! b.isIndexed(t, IndexType.EQUALITY))
143        {
144          throw new ConfigException(ERR_REGEXMAP_ATTR_UNINDEXED.get(
145              configuration.dn(), t.getNameOrOID(), b.getBackendID()));
146        }
147      }
148    }
149
150    // Create the attribute list to include in search requests.  We want to
151    // include all user and operational attributes.
152    requestedAttributes = newLinkedHashSet("*", "+");
153  }
154
155  @Override
156  public void finalizeIdentityMapper()
157  {
158    currentConfig.removeRegularExpressionChangeListener(this);
159  }
160
161  @Override
162  public Entry getEntryForID(String id)
163         throws DirectoryException
164  {
165    RegularExpressionIdentityMapperCfg config = currentConfig;
166    AttributeType[] attributeTypes = this.attributeTypes;
167
168    // Run the provided identifier string through the regular expression pattern
169    // matcher and make the appropriate replacement.
170    Matcher matcher = matchPattern.matcher(id);
171    String processedID = matcher.replaceAll(replacePattern);
172
173    // Construct the search filter to use to make the determination.
174    SearchFilter filter;
175    if (attributeTypes.length == 1)
176    {
177      ByteString value = ByteString.valueOfUtf8(processedID);
178      filter = SearchFilter.createEqualityFilter(attributeTypes[0], value);
179    }
180    else
181    {
182      ArrayList<SearchFilter> filterComps = new ArrayList<>(attributeTypes.length);
183      for (AttributeType t : attributeTypes)
184      {
185        ByteString value = ByteString.valueOfUtf8(processedID);
186        filterComps.add(SearchFilter.createEqualityFilter(t, value));
187      }
188
189      filter = SearchFilter.createORFilter(filterComps);
190    }
191
192    // Iterate through the set of search bases and process an internal search
193    // to find any matching entries.  Since we'll only allow a single match,
194    // then use size and time limits to constrain costly searches resulting from
195    // non-unique or inefficient criteria.
196    Collection<DN> baseDNs = config.getMatchBaseDN();
197    if (baseDNs == null || baseDNs.isEmpty())
198    {
199      baseDNs = DirectoryServer.getPublicNamingContexts().keySet();
200    }
201
202    SearchResultEntry matchingEntry = null;
203    InternalClientConnection conn = getRootConnection();
204    for (DN baseDN : baseDNs)
205    {
206      final SearchRequest request = newSearchRequest(baseDN, SearchScope.WHOLE_SUBTREE, filter)
207          .setSizeLimit(1)
208          .setTimeLimit(10)
209          .addAttribute(requestedAttributes);
210      InternalSearchOperation internalSearch = conn.processSearch(request);
211
212      switch (internalSearch.getResultCode().asEnum())
213      {
214        case SUCCESS:
215          // This is fine.  No action needed.
216          break;
217
218        case NO_SUCH_OBJECT:
219          // The search base doesn't exist.  Not an ideal situation, but we'll
220          // ignore it.
221          break;
222
223        case SIZE_LIMIT_EXCEEDED:
224          // Multiple entries matched the filter.  This is not acceptable.
225          LocalizableMessage message = ERR_REGEXMAP_MULTIPLE_MATCHING_ENTRIES.get(processedID);
226          throw new DirectoryException(
227                  ResultCode.CONSTRAINT_VIOLATION, message);
228
229        case TIME_LIMIT_EXCEEDED:
230        case ADMIN_LIMIT_EXCEEDED:
231          // The search criteria was too inefficient.
232          message = ERR_REGEXMAP_INEFFICIENT_SEARCH.get(processedID, internalSearch.getErrorMessage());
233          throw new DirectoryException(internalSearch.getResultCode(), message);
234
235        default:
236          // Just pass on the failure that was returned for this search.
237          message = ERR_REGEXMAP_SEARCH_FAILED.get(processedID, internalSearch.getErrorMessage());
238          throw new DirectoryException(internalSearch.getResultCode(), message);
239      }
240
241      LinkedList<SearchResultEntry> searchEntries =
242           internalSearch.getSearchEntries();
243      if (searchEntries != null && ! searchEntries.isEmpty())
244      {
245        if (matchingEntry == null)
246        {
247          Iterator<SearchResultEntry> iterator = searchEntries.iterator();
248          matchingEntry = iterator.next();
249          if (iterator.hasNext())
250          {
251            LocalizableMessage message = ERR_REGEXMAP_MULTIPLE_MATCHING_ENTRIES.get(processedID);
252            throw new DirectoryException(ResultCode.CONSTRAINT_VIOLATION, message);
253          }
254        }
255        else
256        {
257          LocalizableMessage message = ERR_REGEXMAP_MULTIPLE_MATCHING_ENTRIES.get(processedID);
258          throw new DirectoryException(ResultCode.CONSTRAINT_VIOLATION, message);
259        }
260      }
261    }
262
263    return matchingEntry;
264  }
265
266  @Override
267  public boolean isConfigurationAcceptable(IdentityMapperCfg configuration,
268                                           List<LocalizableMessage> unacceptableReasons)
269  {
270    RegularExpressionIdentityMapperCfg config =
271         (RegularExpressionIdentityMapperCfg) configuration;
272    return isConfigurationChangeAcceptable(config, unacceptableReasons);
273  }
274
275  @Override
276  public boolean isConfigurationChangeAcceptable(
277                      RegularExpressionIdentityMapperCfg configuration,
278                      List<LocalizableMessage> unacceptableReasons)
279  {
280    boolean configAcceptable = true;
281
282    // Make sure that all of the configured attributes are indexed for equality
283    // in all appropriate backends.
284    Set<DN> cfgBaseDNs = configuration.getMatchBaseDN();
285    if (cfgBaseDNs == null || cfgBaseDNs.isEmpty())
286    {
287      cfgBaseDNs = DirectoryServer.getPublicNamingContexts().keySet();
288    }
289
290    for (AttributeType t : configuration.getMatchAttribute())
291    {
292      for (DN baseDN : cfgBaseDNs)
293      {
294        Backend b = DirectoryServer.getBackend(baseDN);
295        if (b != null && ! b.isIndexed(t, IndexType.EQUALITY))
296        {
297          unacceptableReasons.add(ERR_REGEXMAP_ATTR_UNINDEXED.get(
298              configuration.dn(), t.getNameOrOID(), b.getBackendID()));
299          configAcceptable = false;
300        }
301      }
302    }
303
304    // Make sure that we can parse the match pattern.
305    try
306    {
307      Pattern.compile(configuration.getMatchPattern());
308    }
309    catch (PatternSyntaxException pse)
310    {
311      unacceptableReasons.add(ERR_REGEXMAP_INVALID_MATCH_PATTERN.get(
312          configuration.getMatchPattern(), pse.getMessage()));
313      configAcceptable = false;
314    }
315
316    return configAcceptable;
317  }
318
319  @Override
320  public ConfigChangeResult applyConfigurationChange(
321              RegularExpressionIdentityMapperCfg configuration)
322  {
323    final ConfigChangeResult ccr = new ConfigChangeResult();
324
325    Pattern newMatchPattern = null;
326    try
327    {
328      newMatchPattern = Pattern.compile(configuration.getMatchPattern());
329    }
330    catch (PatternSyntaxException pse)
331    {
332      ccr.addMessage(ERR_REGEXMAP_INVALID_MATCH_PATTERN.get(configuration.getMatchPattern(), pse.getMessage()));
333      ccr.setResultCode(ResultCode.CONSTRAINT_VIOLATION);
334    }
335
336    String newReplacePattern = configuration.getReplacePattern();
337    if (newReplacePattern == null)
338    {
339      newReplacePattern = "";
340    }
341
342    AttributeType[] newAttributeTypes =
343         configuration.getMatchAttribute().toArray(new AttributeType[0]);
344
345    if (ccr.getResultCode() == ResultCode.SUCCESS)
346    {
347      attributeTypes = newAttributeTypes;
348      currentConfig  = configuration;
349      matchPattern   = newMatchPattern;
350      replacePattern = newReplacePattern;
351    }
352
353    return ccr;
354  }
355}