Я бы хотел продолжить цикл технических статей на 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(
host: ENV.fetch("AD_ADDRESS"), port: ENV.fetch("AD_PORT").to_i,
auth: { method: :simple, username: ENV.fetch("AD_USERNAME"), password: ENV.fetch("AD_PASSWORD") },
encryption: { method: :simple_tls, tls_options: { ca_file: ENV.fetch("AD_CERTIFICATE"), verify_mode: OpenSSL::SSL::VERIFY_PEER } }
.tap(&:bind) )
До того как я прозрел про LDAP_MATCHING_RULE_IN_CHAIN
, моя реализация поиска в глубину была такой (до сих пор трудится внутри gitman-а):
def group_members(name, base = PROJECT_GROUPS_DN)
= find(name, base, ["member"])
group return [] unless attribute(group, :member)
.member.map(&method(:dn)).flat_map do |member|
groupif member.include?(", ")
"dn")
user(member, 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.encode(),
filter(attributes),
searchControls{ mapper(it) },
AttributesMapper
processor)
}
).flatMap { it.stream() }.toList()
}
К сожалению, мне не известен простой/удобный способ загружать данные параллельно, в постраничном режиме из AD. Как правило, этого не требуется – запросы достаточно быстро выполняются, задержками можно пренебречь. Чего не скажешь про Atlassian JIRA, запросы к которой могут занимать минуты. Но как известно, на любую хитрую гайку…
fun <T> loadIssues(fields: String, query: String, mapper: (Issue) -> T): List<T> =
.searchIssues(query, fields, 1).total.let { total ->
jiraClientval chunkSize = max(1, total / JIRA_CHUNK_COUNT)
.iterate(0) { it + chunkSize }.limit((total / chunkSize) + 1L).toList().map { start ->
IntStream.supplyAsync {
DatabaseContext.with(RetryPolicy<List<T>>().handle(RestException::class.java).withMaxRetries(3)).get { ->
Failsafe.searchIssues(query, fields, chunkSize, start).issues.map(mapper)
jiraClient}
}
}.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 за ревью.