001package com.nimbusds.infinispan.persistence.ldap.backend;
002
003
004import java.util.LinkedList;
005import java.util.List;
006import java.util.Set;
007import java.util.concurrent.atomic.AtomicInteger;
008
009import com.codahale.metrics.Timer;
010import com.nimbusds.common.appendable.Appendable;
011import com.nimbusds.common.ldap.LDAPConnectionPoolFactory;
012import com.nimbusds.common.ldap.LDAPConnectionPoolMetrics;
013import com.nimbusds.common.ldap.LDAPHealthCheck;
014import com.nimbusds.common.monitor.MonitorRegistries;
015import com.nimbusds.infinispan.persistence.ldap.Loggers;
016import com.nimbusds.infinispan.persistence.ldap.LDAPStoreConfiguration;
017import com.unboundid.asn1.ASN1OctetString;
018import com.unboundid.ldap.sdk.*;
019import com.unboundid.ldap.sdk.controls.SimplePagedResultsControl;
020import net.jcip.annotations.ThreadSafe;
021import org.infinispan.persistence.spi.PersistenceException;
022
023
024/**
025 * LDAP connector to the backend directory. Provides retrieval, addition /
026 * modify and deletion operations.
027 */
028@ThreadSafe
029public class LDAPConnector {
030
031
032        /**
033         * Filter that matches any LDAP entry.
034         */
035        public static final Filter MATCH_ANY_FILTER = Filter.createPresenceFilter("objectClass");
036        
037
038        /**
039         * The LDAP configuration.
040         */
041        private final LDAPStoreConfiguration config;
042
043
044        /**
045         * The LDAP connection pool.
046         */
047        private final LDAPConnectionPool ldapConnPool;
048
049
050        /**
051         * The LDAP request factory.
052         */
053        private final LDAPModifyRequestFactory ldapModifyRequestFactory;
054
055
056        /**
057         * Indicates support for directory attributes that may include options
058         * (such as language tags).
059         */
060        private final boolean supportAttributeOptions;
061
062
063        /**
064         * The LDAP operation timers.
065         */
066        private final LDAPTimers ldapTimers;
067        
068        
069        /**
070         * The name of the associated Infinispan cache / map.
071         */
072        private final String cacheName;
073
074
075        /**
076         * Creates a new LDAP connector.
077         *
078         * @param config                  The LDAP configuration. Must not be
079         *                                {@code null}.
080         * @param cacheName               The name of the Infinispan cache
081         *                                associated with this LDAP connector.
082         *                                Must not be {@code null}.
083         * @param attributes              The names of the supported directory
084         *                                attributes. Must not be empty or
085         *                                {@code null}.
086         * @param supportAttributeOptions {@code true} if any of the supported
087         *                                directory attributes may include
088         *                                options (such as language tags), else
089         *                                {@code false}.
090         */
091        public LDAPConnector(final LDAPStoreConfiguration config,
092                             final String cacheName,
093                             final Set<String> attributes,
094                             final boolean supportAttributeOptions) {
095
096                this.config = config;
097                this.cacheName = cacheName;
098                this.supportAttributeOptions = supportAttributeOptions;
099
100                LDAPConnectionPoolFactory factory = new LDAPConnectionPoolFactory(
101                        config.ldapServer,
102                        config.customTrustStore,
103                        config.customKeyStore,
104                        config.ldapUser);
105
106                try {
107                        ldapConnPool = factory.createLDAPConnectionPool();
108                } catch (Exception e) {
109                        throw new PersistenceException("LDAP connection pool creation for " + cacheName + " cache failed: " + e.getMessage(), e);
110                }
111
112                ldapConnPool.setConnectionPoolName(cacheName);
113
114                checkBaseDN();
115
116                // Setup factory for composing LDAP requests
117                ldapModifyRequestFactory = new LDAPModifyRequestFactory(attributes);
118
119                // Set up DropWizard Metrics
120
121                /// LDAP operation timers
122                ldapTimers = new LDAPTimers(cacheName + ".");
123
124                // Set up LDAP connection pool metrics and health check
125                final String prefix = cacheName + ".ldapStore";
126                MonitorRegistries.register(new LDAPConnectionPoolMetrics(ldapConnPool, prefix));
127                MonitorRegistries.register(prefix, new LDAPHealthCheck(ldapConnPool, config.ldapDirectory.baseDN, Loggers.LDAP_LOG));
128
129                Loggers.MAIN_LOG.info("[IL0100] Created new LDAP store connector for " + cacheName + " cache");
130        }
131
132
133        /**
134         * Checks if the configured directory base DN for the entries exists.
135         * If not a WARN message is logged.
136         */
137        private void checkBaseDN() {
138
139                try {
140                        if (ldapConnPool.getEntry(config.ldapDirectory.baseDN.toString()) == null) {
141
142                                Loggers.MAIN_LOG.warn("[IL0101] The configured LDAP store base DN for {} cache doesn't exist: {}",
143                                        cacheName,
144                                        config.ldapDirectory.baseDN);
145                        }
146
147                } catch (LDAPException e) {
148
149                        Loggers.MAIN_LOG.warn("[IL0102] Couldn't verify the LDAP store base DN for {} cache: {}",
150                                cacheName,
151                                e.getMessage());
152                }
153        }
154
155
156        /**
157         * Returns the underlying LDAP connection pool.
158         *
159         * @return The LDAP connection pool.
160         */
161        public LDAPConnectionPool getPool() {
162
163                return ldapConnPool;
164        }
165
166
167        /**
168         * Retrieves the specified entry from the LDAP directory.
169         *
170         * @param dn The Distinguished Name (DN) of the entry to retrieve. Must
171         *           not be {@code null}.
172         *
173         * @return The matching directory entry, {@code null} if not found (or
174         *         insufficient privileges).
175         */
176        public ReadOnlyEntry retrieveEntry(final DN dn) {
177
178                Timer.Context timerCtx = ldapTimers.getTimer.time();
179                try {
180                        return ldapConnPool.getEntry(dn.toString(), SearchRequest.ALL_USER_ATTRIBUTES);
181                } catch (LDAPException e) {
182                        throw new PersistenceException("LDAP get of " + dn + " failed: " + e.getResultString(), e);
183                } finally {
184                        timerCtx.stop();
185                }
186        }
187
188
189        /**
190         * Retrieves all entries from the LDAP directory under the base DN.
191         *
192         * @param appendable Collects the matching directory entries. Must not
193         *                   be {@code null}.
194         */
195        public void retrieveEntries(final Appendable<ReadOnlyEntry> appendable) {
196
197                SearchRequest searchRequest = new SearchRequest(
198                        config.ldapDirectory.baseDN.toString(),
199                        SearchScope.ONE,
200                        MATCH_ANY_FILTER,
201                        SearchRequest.ALL_USER_ATTRIBUTES);
202
203                doSearch(searchRequest, appendable);
204        }
205
206
207        /**
208         * Checks that the specified entry exists in the LDAP directory.
209         *
210         * @param dn The Distinguished Name (DN) of the entry to check. Must
211         *           not be {@code null}.
212         *
213         * @return {@code true} if the entry exists, {@code false} if not
214         *         found (or insufficient privileges).
215         */
216        public boolean entryExists(final DN dn) {
217
218                Timer.Context timerCtx = ldapTimers.getTimer.time();
219                try {
220                        return ldapConnPool.getEntry(dn.toString(), SearchRequest.NO_ATTRIBUTES) != null;
221                } catch (LDAPException e) {
222                        throw new PersistenceException("LDAP get of " + dn + " failed: " + e.getResultString(), e);
223                } finally {
224                        timerCtx.stop();
225                }
226        }
227
228
229        /**
230         * Adds the specified entry to the LDAP directory.
231         *
232         * @param entry The entry to add. Must not be {@code null}.
233         *
234         * @return {@code true} if the entry was added, {@code false} if there
235         *         was a stored entry with that Distinguished Name (DN).
236         */
237        public boolean addEntry(final ReadOnlyEntry entry) {
238
239                LDAPResult ldapResult;
240
241                Timer.Context timerCtx = ldapTimers.addTimer.time();
242
243                try {
244                        ldapResult = ldapConnPool.add(entry);
245
246                } catch (LDAPException e) {
247
248                        if (e.getResultCode().equals(ResultCode.ENTRY_ALREADY_EXISTS)) {
249                                return false;
250                        }
251
252                        throw new PersistenceException("LDAP add for " + entry.getDN()  + " failed: " + e.getResultString(), e);
253                } finally {
254                        timerCtx.stop();
255                }
256
257                ResultCode resultCode = ldapResult.getResultCode();
258
259                if (resultCode.equals(ResultCode.SUCCESS)) {
260                        return true;
261                }
262
263                if (resultCode.equals(ResultCode.ENTRY_ALREADY_EXISTS)) {
264                        return false;
265                }
266
267                // Other result code indicates exception
268                throw new PersistenceException("LDAP add for " + entry.getDN()  + " failed: " + resultCode.getName());
269        }
270
271
272        /**
273         * Replaces the specified entry in the LDAP directory.
274         *
275         * @param entry The entry to replace. Must not be {@code null}.
276         *
277         * @return {@code true} if the entry was found, {@code false} if
278         *         there was no stored entry with that Distinguished Name (DN).
279         */
280        public boolean replaceEntry(final ReadOnlyEntry entry) {
281
282                final ModifyRequest modifyRequest;
283
284                if (supportAttributeOptions) {
285
286                        DN dn;
287
288                        try {
289                                dn = new DN(entry.getDN());
290                        } catch (LDAPException e) {
291                                throw new PersistenceException(e.getMessage(), e);
292                        }
293                        // Fetch previous entry, otherwise attributes with
294                        // options cannot be reliably updates
295                        ReadOnlyEntry existingEntry = retrieveEntry(dn);
296
297                        if (existingEntry == null) {
298                                return false; // No such entry
299                        }
300
301                        // Create mod based on diff
302                        modifyRequest = ldapModifyRequestFactory.composeModifyRequest(entry, existingEntry);
303
304                        if (modifyRequest == null) {
305                                // No diff detected, indicate entry was found though
306                                return true;
307                        }
308
309                } else {
310
311                        // Create fully-specced mod
312                        modifyRequest = ldapModifyRequestFactory.composeModifyRequest(entry);
313                }
314
315                LDAPResult ldapResult;
316                Timer.Context timerCtx = ldapTimers.modifyTimer.time();
317
318                try {
319                        ldapResult = ldapConnPool.modify(modifyRequest);
320
321                } catch (LDAPException e) {
322
323                        if (e.getResultCode().equals(ResultCode.NO_SUCH_OBJECT)) {
324                                return false;
325                        }
326
327                        throw new PersistenceException("LDAP modify for " + modifyRequest.getDN() + " failed: " + e.getResultString(), e);
328                } finally {
329                        timerCtx.stop();
330                }
331
332                ResultCode resultCode = ldapResult.getResultCode();
333
334                if (resultCode.equals(ResultCode.SUCCESS)) {
335                        return true;
336                }
337
338                if (resultCode.equals(ResultCode.NO_SUCH_OBJECT)) {
339                        return false;
340                }
341
342                throw new PersistenceException("LDAP modify " + modifyRequest.getDN() + " failed: " + resultCode.getName());
343        }
344
345
346        /**
347         * Deletes the specified entry from the LDAP directory.
348         *
349         * @param dn The Distinguished Name (DN) of the entry to delete. Must
350         *           not be {@code null}.
351         *
352         * @return {@code true} if the matching directory entry was deleted,
353         *         {@code false} if not found (or insufficient privileges).
354         */
355        public boolean deleteEntry(final DN dn) {
356
357                DeleteRequest deleteRequest = new DeleteRequest(dn);
358
359                LDAPResult result;
360                Timer.Context timerCtx = ldapTimers.deleteTimer.time();
361
362                try {
363                        result = ldapConnPool.delete(deleteRequest);
364
365                } catch (LDAPException e) {
366
367                        ResultCode resultCode = e.getResultCode();
368
369                        if (resultCode.equals(ResultCode.NO_SUCH_OBJECT))
370                                return false;
371
372                        throw new PersistenceException("LDAP delete of " + dn + " failed: " + e.getResultString(), e);
373                } finally {
374                        timerCtx.stop();
375                }
376
377                ResultCode resultCode = result.getResultCode();
378
379                if (resultCode.equals(ResultCode.SUCCESS)) {
380                        return true;
381                }
382
383                if (resultCode.equals(ResultCode.NO_SUCH_OBJECT)) {
384                        return false;
385                }
386
387                throw new PersistenceException("LDAP delete of " + dn + " failed: " + resultCode.getName());
388        }
389
390
391        /**
392         * Checks if the specified LDAP exception is caused by the LDAP server
393         * being unavailable, disconnected or timing out.
394         *
395         * @param e The LDAP exception. Must not be {@code null}.
396         *
397         * @return {@code true} if the LDAP exception is caused by the LDAP
398         *         server being unavailable, disconnected or timing out, else
399         *         {@code false}.
400         */
401        protected static boolean indicatesConnectionException(final LDAPException e) {
402
403                return indicatesConnectionException(e.getResultCode());
404        }
405
406
407        /**
408         * Checks if the specified LDAP result code indicates the LDAP server
409         * is unavailable, disconnected or timing out.
410         *
411         * @param code The LDAP result code. Must not be {@code null}.
412         *
413         * @return {@code true} if the LDAP result code indicates the LDAP
414         *         server is unavailable, disconnected or timing out, else
415         *         {@code false}.
416         */
417        protected static boolean indicatesConnectionException(final ResultCode code) {
418
419                return code.equals(ResultCode.CONNECT_ERROR)
420                        || code.equals(ResultCode.SERVER_DOWN)
421                        || code.equals(ResultCode.TIMEOUT)
422                        || code.equals(ResultCode.UNAVAILABLE);
423        }
424
425
426        /**
427         * Parses the specified LDAP search result for a page cookie.
428         *
429         * @param sr The LDAP search result. Must not be {@code null}.
430         *
431         * @return The page cookie, {@code null} if not found or undefined.
432         */
433        private static ASN1OctetString parsePageCookie(final SearchResult sr) {
434
435                Control control = sr.getResponseControl(SimplePagedResultsControl.PAGED_RESULTS_OID);
436
437                if (control instanceof SimplePagedResultsControl) {
438                        SimplePagedResultsControl spr = (SimplePagedResultsControl)control;
439                        return spr.getCookie();
440                } else {
441                        return null;
442                }
443        }
444
445
446        /**
447         * Performs an LDAP search with the specified request.
448         *
449         * @param searchRequest The LDAP search request. Must not be
450         *                      {@code null}.
451         * @param appendable    Collects the matching directory entries. Must
452         *                      not be {@code null}.
453         */
454        private void doSearch(final SearchRequest searchRequest, final Appendable<ReadOnlyEntry> appendable) {
455
456                // Check out connection from pool
457                final LDAPConnection connection;
458
459                try {
460                        connection = ldapConnPool.getConnection();
461                } catch (LDAPException e) {
462                        throw new PersistenceException(e.getMessage(), e);
463                }
464
465                ASN1OctetString cookie = null;
466
467                do {
468                        // Set paging cookie if returned from a previous iteration
469                        searchRequest.replaceControl(new SimplePagedResultsControl(config.ldapDirectory.pageSize, cookie));
470
471                        final SearchResult searchResult;
472                        Timer.Context timerCtx = ldapTimers.searchTimer.time();
473
474                        try {
475                                searchResult = connection.search(searchRequest);
476
477                        } catch (LDAPSearchException e) {
478
479                                String msg = "[AS0109] LDAP search " + searchRequest.getFilter() + " failed: " + e.getMessage();
480
481                                if (indicatesConnectionException(e)) {
482
483                                        // Assume connection is unusable
484                                        ldapConnPool.releaseDefunctConnection(connection);
485
486                                } else {
487                                        // Assume connection is still usable
488                                        ldapConnPool.releaseConnection(connection);
489                                }
490
491                                throw new PersistenceException(msg, e);
492                        } finally {
493                                timerCtx.stop();
494                        }
495
496                        cookie = parsePageCookie(searchResult);
497
498                        searchResult.getSearchEntries().forEach(appendable::append);
499
500                } while (cookie != null && cookie.getValueLength() > 0);
501
502                ldapConnPool.releaseConnection(connection);
503        }
504
505
506        /**
507         * Counts the number of entries under the base DN.
508         *
509         * @return The entry count.
510         */
511        public int countEntries() {
512
513                SearchRequest request = new SearchRequest(
514                        config.ldapDirectory.baseDN.toString(),
515                        SearchScope.ONE,
516                        MATCH_ANY_FILTER,
517                        SearchRequest.NO_ATTRIBUTES);
518
519                final AtomicInteger count = new AtomicInteger();
520
521                doSearch(request, entry -> count.incrementAndGet());
522
523                return count.intValue();
524        }
525
526
527        /**
528         * Deletes all entries under the base DN.
529         *
530         * @return The number of deleted entries, zero if none found.
531         */
532        public int deleteEntries() {
533
534                SearchRequest request = new SearchRequest(
535                        config.ldapDirectory.baseDN.toString(),
536                        SearchScope.ONE,
537                        MATCH_ANY_FILTER,
538                        SearchRequest.NO_ATTRIBUTES);
539
540                List<String> entryDNs = new LinkedList<>();
541
542                doSearch(request, entry -> entryDNs.add(entry.getDN()));
543
544                int count = 0;
545
546                for(String dn: entryDNs) {
547
548                        try {
549                                if (deleteEntry(new DN(dn))) {
550                                        ++count;
551                                }
552                        } catch (LDAPException e) {
553                                throw new PersistenceException(e.getMessage(), e);
554                        }
555                }
556
557                return count;
558        }
559
560
561        /**
562         * Shuts down this LDAP connector by releasing any associated resources
563         * (LDAP connection pool).
564         */
565        public void shutdown() {
566
567                ldapConnPool.close();
568
569                if (ldapConnPool.isClosed()) {
570                        Loggers.MAIN_LOG.info("[IL0107] Shut down LDAP connector for {} cache", cacheName);
571                } else {
572                        Loggers.MAIN_LOG.error("[IL0108] Attempted to shut down LDAP connector for {} cache, detected unreleased LDAP connections", cacheName);
573                }
574        }
575}