LDAP tips and tricks

October 29, 2019 &russian @code #ldap

Я бы хотел продолжить цикл технических статей на Workplace рассказом о том, с какими трудностями можно столкнуться при работе с Active Directory по LDAP протоколу. Полноценной статьей такой рассказ назвать сложно, скорее – сборник рецептов. Стоит заранее оговориться – никаких упоминаний о Windows и PowerShell в статье нет, это тема очень обширна и заслуживает отдельной публикации (а может и нескольких).

Дата и время

Первое, что бросается в глаза – это незнакомый формат даты-времени. Даже не формат, а форматы – их несколько. Бывают записи вида whenChanged: 20191028073233.0Z с очевидным форматом, но есть еще и записи вида pwdLastSet: 132119732272806390 о сути которых лучше меня расскажет страница. Приходится писать свои parser/emitter-ы для этих форматов, так как в поставку библиотек он редко входят. Дата и время – очень обширная тема, приглашаю послушать Kovsh Dmitry на предстоящем Itransition Development Meetup #10.

We need to go deeper

Как правило, объекты в AD (группы, отделы, пользователи) имеют древовидную структуру, напоминающую (или повторяющую) иерархию реальных организаций. Наша – не исключение (закон Конвея, как-никак). Классические утилиты для работы с AD и, тем более, API – позволяют осуществлять поиск только на один уровень вглубь. Рассмотрим на примере – существует группа RFX Digest Readers, в состав которой входят как пользовательские учетные записи, так и другие группы

member: CN=Tech Coordinators,OU=Groups
member: CN=Marketing,OU=Groups
member: CN=Departments Managers,OU=Groups
member: CN=Syomkin, Andrey,OU=Active,OU=Users
member: CN=Chernikov, Yury,OU=Active,OU=Users

Наивное решение для поиска всех людей в этой группе – такое:

ldapsearch -h ldap.itransition.corp -b "OU=Active,OU=Users,OU=Itransition,DC=itransition,DC=corp" "memberOf=CN=RFX Digest Readers,OU=ServiceGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp" displayName

Однако результат предсказуем – нашлись только два человека:

member: CN=Syomkin, Andrey,OU=Active,OU=Users
member: CN=Chernikov, Yury,OU=Active,OU=Users

Для того, чтобы найти всех, необходимо прибегнуть к “особой Microsoft магии” – специальным префиксам. Например префикс :1.2.840.113556.1.4.1941: (значение именно такое, по иторическим причинам легенде такое значение соответствовало ключу LDAP_MATCHING_RULE_IN_CHAIN в header файлах) позволяет “искать вглубь”. Такой поиск вернет все необходимые записи.

ldapsearch -Q -h ldap.itransition.corp -b "OU=Active,OU=Users,OU=Itransition,DC=itransition,DC=corp" "memberOf:1.2.840.113556.1.4.1941:=CN=RFX Digest Readers,OU=ServiceGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp" displayName | grep displayName: | wc -l

=> 113

Синхронизация

Если изменение только-только произошло – не спешите стремглав выполнять поиск по AD, в друг там несколько серверов с репликацией и они еще не синхронизировались… О способах и времени синхронизации отдельных серверов в AD ходят легенды. Мой совет – работайте на запись всегда только с одним, конкретным сервером, если возможно. Совет вредный, но рабочий. Буду раз услышать в комментариях совет полезный, но пока так ;)

TLS соединение

“Общаться” с AD лучше по защищенному соединению. LDAP поддерживает как несколько режимов аутентификации – kerberos, simple, так и несколько способов шифровать соединение – TLS, START_TLS. Я использую kerberos для работы через консольную утилиту ldapsearch, так как это очень удобно – единожды получив “тикет” (через kinit) можно не утруждаться вводом логина/пароля при каждом поиске. Поддержка kerberos традиционно сильна в Java мире (где NTLM не очень популярен), так что я этот метод так же использую в своих Kotlin приложениях. Simple аутентификация пригодится там, где нет возможности пользоваться развесистыми библиотеками – например в ruby. Но стоит себя дополнительно обезопасить, применив TLS шифрование:

@ldap = Net::LDAP.new(
  ENV.fetch("AD_ADDRESS"), ENV.fetch("AD_PORT").to_i,
  { , ENV.fetch("AD_USERNAME"), ENV.fetch("AD_PASSWORD") },
  { , { ENV.fetch("AD_CERTIFICATE"), OpenSSL::SSL::VERIFY_PEER } }
).tap(&)

До того как я прозрел про LDAP_MATCHING_RULE_IN_CHAIN, моя реализация поиска в глубину была такой (до сих пор трудится внутри gitman-а):

def group_members(name, base = PROJECT_GROUPS_DN)
  group = find(name, base, ["member"])
  return [] unless attribute(group, )

  group.member.map(&method()).flat_map do |member|
    if member.include?(", ")
      user(member, "dn")
    else
      group_members(member, base)
    end
  end.compact.uniq
end

Постраничный доступ

При получении большого количества результатов – ldapsearch по умолчанию выдает только первую 1000 штук. Заставить его “выдать всех” можно используя аргумент -E pr=2147483647/noprompt. Он заставит ldapsearch установить очень большой размер “страницы” и не спрашивать “продлевать будете?” при достижении ее границы. При работе из кода (Java, Kotlin), удобно пользоваться createStreamFromIterator из StreamUtils, он позволяет преобразовывать последовательность вызовов next() итератора в удобный для работы поток:

fun loadAll(
    ldapName: Name,
    filter: AbstractFilter,
    attributeClass: KClass<out LDAPAttribute>,
    mapper: (Attributes) -> T
): List<T> {
    val attributes = attributeClass.sealedSubclasses.map { it.objectInstance!! }
    val processor = PagedResultsDirContextProcessor(LDAP_PAGE_SIZE)

    return StreamUtils.createStreamFromIterator(
        object : Iterator<List<T>> {
            override fun hasNext() = processor.hasMore()
            override fun next() = LdapTemplate(contextSource).search(
                ldapName,
                filter.encode(),
                searchControls(attributes),
                AttributesMapper { mapper(it) },
                processor
            )
        }
    ).flatMap { it.stream() }.toList()
}

К сожалению, мне не известен простой/удобный способ загружать данные параллельно, в постраничном режиме из AD. Как правило, этого не требуется – запросы достаточно быстро выполняются, задержками можно пренебречь. Чего не скажешь про Atlassian JIRA, запросы к которой могут занимать минуты. Но как известно, на любую хитрую гайку…

fun <T> loadIssues(fields: String, query: String, mapper: (Issue) -> T): List<T> =
    jiraClient.searchIssues(query, fields, 1).total.let { total ->
        val chunkSize = max(1, total / JIRA_CHUNK_COUNT)
        IntStream.iterate(0) { it + chunkSize }.limit((total / chunkSize) + 1L).toList().map { start ->
            DatabaseContext.supplyAsync {
                Failsafe.with(RetryPolicy<List<T>>().handle(RestException::class.java).withMaxRetries(3)).get { ->
                    jiraClient.searchIssues(query, fields, chunkSize, start).issues.map(mapper)
                }
            }
        }.map { it.get() }.flatten()
    }

Примеры запросов

Запросы в AD могут быть весьма сложными, так сказать write-only. Приведу, один такой пример.

(&(objectClass=person)(|
(memberOf:1.2.840.113556.1.4.1941:=CN=HelpDesk.Operators,OU=ServiceGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(managedObjects:1.2.840.113556.1.4.1941:=CN=HelpDesk.Operators,OU=ServiceGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(msExchCoManagedObjectsBL:1.2.840.113556.1.4.1941:=CN=HelpDesk.Operators,OU=ServiceGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(memberOf:1.2.840.113556.1.4.1941:=CN=IT,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(managedObjects:1.2.840.113556.1.4.1941:=CN=IT,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(msExchCoManagedObjectsBL:1.2.840.113556.1.4.1941:=CN=IT,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(memberOf:1.2.840.113556.1.4.1941:=CN=VECTOR.Development,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(managedObjects:1.2.840.113556.1.4.1941:=CN=VECTOR.Development,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp)
(msExchCoManagedObjectsBL:1.2.840.113556.1.4.1941:=CN=VECTOR.Development,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp)))

На человеческом языке запрос звучит достаточно просто: все HelpDesk Operator-ы, а так же члены групп IT и VECTOR.Development. Из этой безобидности запрос превращается в “ужас на крыльях ночи” благодаря: - обсуждаемому выше магическому префиксу для поиска “в глубину” - наличию не только членов группы (memberOf), но и ее владельцев (managedObjects) - кроме очевидного способа найти владельцев группы, существует еще и Exchange-way управлять составом групп, так что приходится учитывать поле msExchCoManagedObjectsBL

Предыдущий запрос используется в конфигурационном файле nginx для контроля доступа к страницам при помощи nginx-auth-ldap плагина. Такое решение может оказаться эффективнее, чем встраивать аутентификацию внутрь защищаемой системы. Если вам это необходимо – скорее всего придется собрать модуль самостоятельно

RUN git clone https://github.com/kvspb/nginx-auth-ldap.git && wget -qO- http://nginx.org/download/nginx-1.17.3.tar.gz | tar -xvzf -

RUN cd nginx-1.17.3 && ./configure  --user=nginx \
                                    --group=nginx \
                                    --prefix=/etc/nginx \
                                    --sbin-path=/usr/sbin/nginx \
                                    --conf-path=/etc/nginx/nginx.conf \
                                    --pid-path=/var/run/nginx.pid \
                                    --lock-path=/var/run/nginx.lock \
                                    --error-log-path=/var/log/nginx/error.log \
                                    --http-log-path=/var/log/nginx/access.log \
                                    --with-http_gzip_static_module \
                                    --with-http_ssl_module \
                                    --with-pcre \
                                    --add-module=/nginx-auth-ldap/ \
                                    --with-debug && make && make install && rm -rf /nginx-1.17.3 && rm -rf /nginx-auth-ldap

Аутентификация в TeamCity

Множество систем поддерживает интеграцию с AD в качестве источника пользователей. Вот рецепт (содержимое необходимо поместить в teamcity_server/datadir/config/ldap-config.properties файл) того, как это можно сделать в TeamCity, чтобы члены проектной команды могли логиниться под доменными учетными записями. Доступ будет автоматически пропадать при покидании проекта. Все что для этого понадобится – служебная учетная запись (для доступа к самому AD).

java.naming.provider.url=ldap://ldap.itransition.corp:389
java.naming.security.principal=CN=vector,OU=ServiceAccounts,OU=Users,OU=Itransition,DC=itransition,DC=corp
java.naming.security.credentials=PASSWORD_WAS_HERE

teamcity.users.login.filter=(&(sAMAccountName=$capturedLogin$)(memberOf:1.2.840.113556.1.4.1941:=CN=VECTOR.Development,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp))

teamcity.options.users.synchronize=true
teamcity.users.filter=(&(objectClass=person)(memberOf:1.2.840.113556.1.4.1941:=CN=VECTOR.Development,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp))

teamcity.users.base=OU=Active,OU=Users,OU=Itransition,DC=itransition,DC=corp
teamcity.users.username=sAMAccountName
teamcity.users.property.displayName=displayName
teamcity.users.property.email=mail

teamcity.options.createUsers=true
teamcity.options.deleteUsers=true

Подробный рассказ о TeamCity и то, как его правильно готовить от Panfilenok Aleksandr вас ждет на предстоящем Itransition Development Meetup #10.

Утилиты командной строки

Используя ldapseach можно легко (для тех кто любит терминал) строить полезные запросы к AD. Например – список людей, в интересующей вас комнате:

ldapsearch -Q -h ldap.itransition.corp -b "OU=Active,OU=Users,OU=Itransition,DC=itransition,DC=corp" "(&(physicalDeliveryOfficeName=118a)(streetAddress=Kulman, 1))" | grep displayName:

=>
displayName: Shestakov, Aleksandr
displayName: Pupkin, Andrey

Список проектных групп вашего проекта (результат зависит от консистентности названия групп):

ldapsearch -o ldif-wrap=no -Q -h ldap.itransition.corp -b "OU=Groups,OU=Itransition,DC=itransition,DC=corp" "name=ILLIS*" dn | grep CN=

=>
dn: CN=ILLIS.Development,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.DevOps,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.Ecomm,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.Feature,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.FrontEnd,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.Helpdesk,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.ILLIS-PLUS,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.Platform,OU=ProjectGroups,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.PM-BA,OU=MailLists,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.QA,OU=A1QA.com,OU=Groups,OU=Itransition,DC=itransition,DC=corp
dn: CN=ILLIS.TL,OU=MailLists,OU=Groups,OU=Itransition,DC=itransition,DC=corp

Список переговорных на Кульман 1 с окнами:

ldapsearch -Q -h ldap.itransition.corp -b "OU=ConferenceRooms,OU=ServiceAccounts,OU=Users,OU=Itransition,DC=itransition,DC=corp" "(&(name=Room K1-*)(msExchResourceDisplay=*NaturalLight*))" dn | grep CN= | cut -d , -f 1

=>
dn: CN=Room K1-101
dn: CN=Room K1-117
dn: CN=Room K1-119
dn: CN=Room K1-202
dn: CN=Room K1-208
dn: CN=Room K1-421

Или без окон:

ldapsearch -Q -h ldap.itransition.corp -b "OU=ConferenceRooms,OU=ServiceAccounts,OU=Users,OU=Itransition,DC=itransition,DC=corp" "(&(name=Room K1-*)(!(msExchResourceDisplay=*NaturalLight*)))" dn | grep CN= | cut -d , -f 1

=>
dn: CN=Room K1-112
dn: CN=Room K1-217
dn: CN=Room K1-224
dn: CN=Room K1-301a-Manicure
dn: CN=Room K1-301a-Massage
dn: CN=Room K1-313a-1
dn: CN=Room K1-315
dn: CN=Room K1-316
dn: CN=Room K1-410
dn: CN=Room K1-411
dn: CN=Room K1-419

Список однофамильцев рабочих станций:

ldapsearch -o ldif-wrap=no -Q -h ldap.itransition.corp -b "OU=Workstations,OU=Itransition,DC=itransition,DC=corp" "(&(name=shestakova*)(!(name=shestakova-a)))" managedBy

=>
# extended LDIF
#
# LDAPv3
# base <OU=Workstations,OU=Itransition,DC=itransition,DC=corp> with scope subtree
# filter: (&(name=shestakova*)(!(name=shestakova-a)))
# requesting: managedBy
#

# SHESTAKOVAV, Workstations, Itransition, itransition.corp
dn: CN=SHESTAKOVAV,OU=Workstations,OU=Itransition,DC=itransition,DC=corp
managedBy: CN=Shestakova\, Vitalina,OU=Active,OU=Users,OU=Itransition,DC=itransition,DC=corp

# search result
search: 5
result: 0 Success

# numResponses: 2
# numEntries: 1

Занимательные флаги

При работе с утилитой командной строки ldapsearch необходимо быть внимательным – она любит делать text-wrap, перенося окончания длинных CN на следующую строку. Для того, чтобы этого избежать – удобно пользоваться опцией -o ldif-wrap=no.

Знаете первое правило real estate бизнеса – Location, Location и еще раз Location. Так вот ldapsearch тоже о нем в курсе ;) Для того, чтобы избавиться от надоедливых header-ов (иначе не удобно обрабатывать вывод другими утилитами), необходимо передавать флаги -L -L -L, о чем заботливо упоминается в документации: A single -L restricts the output to LDIFv1. A second -L disables comments. A third -L disables printing of the LDIF version.

Флаг -Q тоже полезен, он “глушит” вывод SASL/GSSAPI библиотеки (реализация kerberos) в потоке вывода.

Внимательный читатель заметил символ экранирования – обратный slash, который ldapsearch вставляет при печати результатов поиска (причем в комментариях иным способом: # Shestakov\2C Aleksandr). Дело в том, что запятая – “разделитель пути” в DN (distinguishable name – уникальный идентификатор объекта в AD). Так что при обработке вывода ldapseach лучше использовать другие поля, например displayName. Это бывает полезно еще и по причине того, что displayName может не совпадать по написанию с DN – так бывает, если пользователь очень-пре-очень хочет, чтобы в корпоративных системах его имя или фамилия имели отличное от принятого стандарта транслитерации.

Ну вот и все, спасибо за внимание ;) Выражаю благодарность Neskoromny Nikolay за идею статьи, Sovetkin Maksim за ревью.