Интеграция JIRA 4.1 с Active Directory

в 14:22, , рубрики: active directory, jira, spnego, tomcat, windows, системное администрирование, метки: , , ,

Встала задача интеграции JIRA 4.1 с Active Directory со следующими условиями:

  1. Синхронизация пользователей и групп (версия 4.1 не поддерживает синхронизацию)
  2. Прозрачная аутентификация (в качестве логинов в JIRA используются почтовые адреса)
  3. Заполнение свойства пользователей (номер телефона)
  4. Пользователи, которых нет в AD, должны быть помечены

И вот как я её решил:

Топик опубликован по просьбеина XoJIoD — так что плюсы ему.

Настройка JIRA

В JIRA 4.1 есть готовый механизм интеграции с LDAP, но единственное, что он умеет — делать проверку паролей, т.е. ни о какой синхронизации речи не идет. Данный шаг можно пропустить, т.к. в дальнейшем мы доверим проверку паролей SPNEGO.

Итак, заходим в JIRA под учетной записью администратора и переходим в раздел Administration → System → LDAP и заполняем форму:
image
, где:

  • LDAP Host — адрес и порт вашего сервера LDAP
  • BaseDN — корневой DN, в котором искать пользователей
  • Bind DN — DN пользователя, который используется для подключения к серверу LDAP
  • Bind Password — пароль пользователя, который используется для подключения
  • Search Attribute — атрибут, в котором содержится логин пользователя (в моем случае это адрес почты)
  • Sample user to authenticate — логин пользователя, который будет использован для проверки соединения
  • Sample user's password — пароль пользователя для проверки соединения

Если появилось сообщение об ошибке PartialResultException, попробуйте использовать порт Global Catalog (3268). Если всё заполнено правильно, должно появиться сообщение LDAP Authentication successful и, чуть ниже, содержимое XML-файла для интеграции. Скопируйте его, остановите сервер JIRA и замените этим содержимым то, что было в файле $JIRA_HOME/atlassian-jira/WEB-INF/classes/osuser.xml. Пользуясь случаем также включим выполнение Jelly-скриптов, для этого в файле $JIRA_HOME/bin/setenv.sh (для Linux) добавим в параметр JAVA_OPTS строку -Djira.jelly.on=true.

После этого вновь запустите JIRA и убедитесь, что для пользователей, которые есть в AD, используются пароли оттуда.

Создание Jelly-скрипта

Программу для генерации скрипта, создающего пользователей и группы, я написал в самом начале, а потом жалко было выкидывать. Поэтому, если добавить в аутентификатор или службу (см. ниже) создание групп, этот шаг также можно пропустить.

В документации на сайте Atlassian была найдена утилита с открытым исходным кодом, которая генерирует Jelly-скрипт, создающий пользователей LDAP в JIRA. Однако, мне также нужно было создавать группы, поэтому, вооружившись документацией по Jelly-тегам, дописал эту утилиту до такого состояния:

import java.io.File;
import java.io.FileReader;
import java.util.Properties;
import java.util.Random;
import javax.naming.NamingEnumeration;
import javax.naming.directory.*;
import org.apache.commons.lang3.StringEscapeUtils;

public class LDAPImporter {

    public static void main(String[] args) throws Exception {
        Properties properties = new Properties();
        File path = new File(LDAPImporter.class.getProtectionDomain()
                .getCodeSource().getLocation().toURI());
        if (!path.isDirectory()) {
            path = path.getParentFile();
        }
        properties.load(new FileReader(new File(path, "LDAP.properties")));
        properties.put("java.naming.factory.initial",
                "com.sun.jndi.ldap.LdapCtxFactory");

        DirContext groupContext = new InitialDirContext(properties);
        SearchControls groupControls = new SearchControls();
        groupControls.setReturningAttributes(new String[] {
                (String) properties.get("groupNameAttribute"),
                (String) properties.get("groupDNAttribute") });
        groupControls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        System.out.println("<JiraJelly xmlns:jira="jelly:com.atlassian.jira.jelly.JiraTagLib">n");

        NamingEnumeration results = groupContext.search(
                (String) properties.get("baseDN"),
                (String) properties.get("groupFilter"), groupControls);

        // Цикл по группам (Organisation Unit'ам)
        while (results != null && results.hasMoreElements()) {
            SearchResult result = (SearchResult) results.next();
            Attribute attribute = result.getAttributes().get(
                    (String) properties.get("groupNameAttribute"));
            if (attribute == null) {
                continue;
            }
            String groupName = (String) attribute.get();
            String groupDN = (String) result.getAttributes()
                    .get((String) properties.get("groupDNAttribute")).get();

            // Создание группы
            System.out.println("<jira:CreateGroup group-name=""
                    + StringEscapeUtils.escapeXml("[AD] " + groupName)
                    + ""/>n");

            DirContext userContext = new InitialDirContext(properties);
            SearchControls userControls = new SearchControls();
            userControls.setReturningAttributes(new String[] {
                    (String) properties.get("userLoginAttribute"),
                    (String) properties.get("userNameAttribute"),
                    (String) properties.get("userMailAttribute") });
            userControls.setSearchScope(SearchControls.ONELEVEL_SCOPE);

            NamingEnumeration users = userContext.search(groupDN,
                    (String) properties.get("userFilter"), userControls);
            
            // Цикл по пользователям в группе
            while (users != null && users.hasMoreElements()) {
                SearchResult user = (SearchResult) users.next();
                attribute = user.getAttributes().get(
                        (String) properties.get("userLoginAttribute"));
                if (attribute == null) {
                    continue;
                }
                String userLogin = (String) attribute.get();
                attribute = user.getAttributes().get(
                        (String) properties.get("userNameAttribute"));
                String userName;
                if (attribute != null) {
                    userName = (String) attribute.get();
                } else {
                    userName = userLogin;
                }
                attribute = user.getAttributes().get(
                        (String) properties.get("userMailAttribute"));
                String userMail;
                if (attribute != null) {
                    userMail = (String) attribute.get();
                } else {
                    userMail = userLogin.replace(' ', '.') + '@'
                            + properties.get("userMailDomain");
                }
                /*
                 * Т.к. будут использоваться пароли из AD,
                 * генерируем случайный пароль
                 */
                String password = Integer.toHexString(new Random().nextInt());

                // Создание пользователя
                System.out.println("<jira:CreateUser username=""
                        + StringEscapeUtils.escapeXml(userLogin.toLowerCase())
                        + "" password="" + password + "" confirm=""
                        + password + "" fullname=""
                        + StringEscapeUtils.escapeXml(userName) + "" email=""
                        + StringEscapeUtils.escapeXml(userMail) + ""/>n");

                // Добавление пользователя в группу
                System.out.println("<jira:AddUserToGroup username=""
                        + StringEscapeUtils.escapeXml(userLogin.toLowerCase())
                        + "" group-name=""
                        + StringEscapeUtils.escapeXml("[AD] " + groupName)
                        + ""/>n");
            }
        }

        System.out.println("</JiraJelly>");
    }
}

Настройки берутся из файла LDAP.properties, находящемся в одной директории с программой, выглядит он примерно так:

# Адрес сервера LDAP
java.naming.provider.url=ldap://127.0.0.1:389
# DN пользователя для подключения
java.naming.security.principal=cn=test,ou=users,dc=local,dc=domain
# Пароль пользователя
java.naming.security.credentials=password
# Корневой DN
baseDN=ou=users,dc=local,dc=domain
# Атрибут с именем группы
groupNameAttribute=name
# Атрибут с DN группы
groupDNAttribute=distinguishedName
# Фильтр групп
groupFilter=(objectclass=organizationalUnit)
# Атрибут с логином пользователя
userLoginAttribute=mail
# Атрибут с именем пользователя (если не указано, равно логину)
userNameAttribute=displayName
# Атрибут с адресом e-mail пользователя (если не указано, равно логин + @ + почтовый домен)
userMailAttribute=mail
# Почтовый домен
userMailDomain=local.domain
# Фильтр пользователей
userFilter=(objectclass=user)

Полученный таким образом скрипт скармливаем Jira в разделе Administration → Options & Settings → Jelly Runner и она, немного подумав, создает пользователей и группы.

Прозрачная аутентификация

Идея прозрачной аутентификации заключается в следующем: аутентифицирует пользователей SPNEGO и передаёт JIRA в переменной REMOTE USER логин уже вошедшего пользователя. Минус заключается в том, что при такой схеме пользователи, которых нет в AD, зайти в JIRA не смогут, однако лучше я ничего не придумал.

SPNEGO

Для начала проверяем работоспособность SPNEGO. Для этого скачиваем три файла: krb5.conf, login.conf и HelloKDC.java.
В файле krb5.conf в секции [libdefaults] пропущена одна строка, не забудьте её вписать. После изменения настроек файл krb5.conf должен выглядеть примерно так:

[libdefaults]
        default_realm = LOCAL.DOMAIN
        default_tkt_enctypes = aes128-cts rc4-hmac des3-cbc-sha1 des-cbc-md5 des-cbc-crc
        default_tgs_enctypes = aes128-cts rc4-hmac des3-cbc-sha1 des-cbc-md5 des-cbc-crc
        permitted_enctypes   = aes128-cts rc4-hmac des3-cbc-sha1 des-cbc-md5 des-cbc-crc

[realms]
        LOCAL.DOMAIN  = {
                kdc = dc5.local.domain
                default_domain = LOCAL.DOMAIN
}

[domain_realm]
        .LOCAL.DOMAIN = LOCAL.DOMAIN

HelloKDC.java:

...
        // Domain (pre-authentication) account
        final String username = "test";
        
        // Password for the pre-auth acct.
        final String password = "password";
        
        // Name of our krb5 config file
        final String krbfile = "krb5.conf";
        
        // Name of our login config file
        final String loginfile = "login.conf";
        
        // Name of our login module
        final String module = "spnego-client";
...

Скомпилируйте и запустите файл HelloKDC.java, если всё прошло успешно, он выведет кучу строк и в конце Connection test successful.

Далее скачайте набор утилит Support Tools, установите их на доменный компьютер администратора и выполните команды:

setspn.exe -A HTTP/domain1 test
setspn.exe -A HTTP/domain2 test
...
setspn.exe -A HTTP/domainN test

, где

  • test — логин пользователя, под которым SPNEGO будет заходить в AD
  • domain* — все возможные имена, по которым пользователям будет доступна JIRA (например, jira, jira.local.domain, и т.д.)

Скачайте дистрибутив SPNEGO и положите его в директорию $JIRA_HOME/lib. Откройте файл $JIRA_HOME/conf/web.xml и добавьте в конец (но до закрытия тега web-app) следующие строки:

<filter>
    <filter-name>SpnegoHttpFilter</filter-name>
    <filter-class>net.sourceforge.spnego.SpnegoHttpFilter</filter-class>

    <init-param>
        <param-name>spnego.allow.basic</param-name>
        <param-value>true</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.allow.localhost</param-name>
        <param-value>true</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.allow.unsecure.basic</param-name>
        <param-value>true</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.login.client.module</param-name>
        <param-value>spnego-client</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.krb5.conf</param-name>
        <param-value>krb5.conf</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.login.conf</param-name>
        <param-value>login.conf</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.preauth.username</param-name>
        <param-value>test</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.preauth.password</param-name>
        <param-value>password</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.login.server.module</param-name>
        <param-value>spnego-server</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.prompt.ntlm</param-name>
        <param-value>true</param-value>
    </init-param>
    
    <init-param>
        <param-name>spnego.logger.level</param-name>
        <param-value>1</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>SpnegoHttpFilter</filter-name>
    <url-pattern>*</url-pattern>
</filter-mapping>

Не забудьте заменить имя пользователя и пароль на свои.

Аутентификатор

Пришло время написать аутентификатор, в задачи которого входит получение логина вошедшего пользователя от SPNEGO, добавления к этому логину почтового домена (т.к. логинами в JIRA выступают почтовые адреса) и заполнение свойства, содержащего телефон, у пользователей, у которых этого свойства ещё нет. В поставке SDK для плагинов JIRA 4.x не нашлось место классу JiraOsUserAuthenticator, на основе которого будет написан наш аутентификатор, т.к. этот класс уже является устаревшим. Поэтому его надо вручную добавить в библиотеки при компиляции, взять можно в $JIRA_HOME/atlassian-jira/WEB-INF/classes/com/atlassian/jira/security/login/.

Собственно, код аутентификатора:

import java.security.Principal;
import java.util.Properties;
import java.util.Random;

import javax.naming.NamingEnumeration;
import javax.naming.directory.Attribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.atlassian.core.user.preferences.Preferences;
import com.atlassian.jira.ComponentManager;
import com.atlassian.jira.security.login.JiraOsUserAuthenticator;
import com.atlassian.jira.user.preferences.UserPreferencesManager;
import com.atlassian.jira.user.util.UserUtil;
import com.opensymphony.user.Group;
import com.opensymphony.user.User;

@SuppressWarnings("deprecation")
public class RemoteAuthenticator extends JiraOsUserAuthenticator {

    private static final long serialVersionUID = 1L;
    private UserPreferencesManager preferencesManager = ComponentManager
            .getInstance().getUserPreferencesManager();

    // Адрес сервера LDAP
    private final String ADDRESS_NAME = "java.naming.provider.url";
    private final String ADDRESS_VALUE = "ldap://127.0.0.1:389";

    // DN пользователя для соединения
    private final String LOGIN_NAME = "java.naming.security.principal";
    private final String LOGIN_VALUE = "cn=test,ou=users,dc=local,dc=domain";

    // Пароль пользователя
    private final String PASSWORD_NAME = "java.naming.security.credentials";
    private final String PASSWORD_VALUE = "password";

    // Базовый DN
    private final String BASEDN = "ou=users,dc=local,dc=domain";

    // Атрибут логина
    private final String LOGIN_ATTRIBUTE = "mail";

    // Атрибут имени (если не указано, равно логину)
    private final String NAME_ATTRIBUTE = "displayName";

    // Атрибут почтового адреса (если не указано,
    // равен логин + @ + почтовый домен)
    private final String MAIL_ATTRIBUTE = "mail";

    // Атрибут телефона
    private final String PHONE_ATTRIBUTE = "telephoneNumber";

    // Почтовый домен
    private final String DOMAIN = "local.domain";

    @Override
    public Principal getUser(HttpServletRequest request,
            HttpServletResponse response) {
        Principal user = null;
        if (request.getSession() != null && request.getSession().
                getAttribute(JiraOsUserAuthenticator.LOGGED_IN_KEY) != null) {
            // Пользователь уже авторизован
            user = (Principal) request.getSession().getAttribute(
                    JiraOsUserAuthenticator.LOGGED_IN_KEY);
        } else {
            String remote = request.getRemoteUser();
            if (remote != null) {
                // Получен логин от SPNEGO
                if (!remote.endsWith('@' + DOMAIN)) {
                    /*
                     * Добавляем к логину почтовый домен,
                     * чтобы получить почтовый адрес.
                     * Данный метод вызывается дважды при входе,
                     * поэтому добавлять надо только однажды
                     */
                    remote += '@' + DOMAIN;
                }
                
                user = getUser(remote.toLowerCase());
                if (user == null) {
                    /*
                     * Пользователя с таким логином нет в JIRA,
                     * создаем
                     */
                    try {
                        user = createUser(remote.toLowerCase());
                    } catch (Exception exception) {
                        exception.printStackTrace(System.err);
                    }
                } else {
                    Preferences preferences = preferencesManager
                            .getPreferences((User) user);
                    String phone = preferences
                            .getString(UserUtil.META_PROPERTY_PREFIX + "mobile");
                    
                    if (phone == null || phone.isEmpty()) {
                        /*
                         * У пользователя не заполнено свойство с телефоном,
                         * заполняем
                         */
                        try {
                            phone = getPhone(remote.toLowerCase());
                            if (phone != null) {
                                preferences.setString(
                                        UserUtil.META_PROPERTY_PREFIX
                                                + "mobile", phone);
                            }
                        } catch (Exception exception) {
                            exception.printStackTrace(System.err);
                        }
                    }
                }
                request.getSession().setAttribute(
                        JiraOsUserAuthenticator.LOGGED_IN_KEY, user);
                request.getSession().setAttribute(
                        JiraOsUserAuthenticator.LOGGED_OUT_KEY, null);
            }
        }
        return user;
    }

    private User createUser(String login) throws Exception {
        DirContext context = null;
        try {
            Properties properties = new Properties();
            properties.put(ADDRESS_NAME, ADDRESS_VALUE);
            properties.put(LOGIN_NAME, LOGIN_VALUE);
            properties.put(PASSWORD_NAME, PASSWORD_VALUE);
            properties.put("java.naming.factory.initial",
                    "com.sun.jndi.ldap.LdapCtxFactory");
            ComponentManager componentManager = ComponentManager.getInstance();
            UserUtil userUtil = componentManager.getUserUtil();
            context = new InitialDirContext(properties);
            SearchControls controls = new SearchControls();

            controls.setReturningAttributes(new String[] { "distinguishedName",
                    NAME_ATTRIBUTE, MAIL_ATTRIBUTE, PHONE_ATTRIBUTE });
            controls.setSearchScope(SearchControls.SUBTREE_SCOPE);

            NamingEnumeration users = context.search(BASEDN, '('
                    + LOGIN_ATTRIBUTE + '=' + login + ')', controls);
            
            if (users != null && users.hasMoreElements()) {
                /*
                 * Пользователь с таким логином (почтовым адресом)
                 * найден в AD
                 */
                SearchResult result = (SearchResult) users.next();
                
                Attribute attribute = result.getAttributes()
                        .get(NAME_ATTRIBUTE);
                String userName;
                if (attribute != null) {
                    userName = (String) attribute.get();
                } else {
                    userName = login;
                }
                
                attribute = result.getAttributes().get(MAIL_ATTRIBUTE);
                String userMail;
                if (attribute != null) {
                    userMail = (String) attribute.get();
                } else {
                    userMail = login.replace(' ', '.') + '@' + DOMAIN;
                }
                
                attribute = result.getAttributes().get(PHONE_ATTRIBUTE);
                String userPhone = null;
                if (attribute != null) {
                    userPhone = ((String) attribute.get()).replace("(", "")
                            .replace(")", "");
                }
                
                String userDN = (String) result.getAttributes()
                        .get("distinguishedName").get();
                // Генерируем случайный пароль
                String userPassword = Integer.toHexString(new Random().nextInt());

                // Создаем пользователя
                User user = userUtil.createUserNoEvent(login, userPassword,
                        userMail, userName);
                
                // Если в AD указан телефон, заполняем свойство
                if (userPhone != null) {
                    preferencesManager.getPreferences(user).setString(
                            UserUtil.META_PROPERTY_PREFIX + "mobile", userPhone);
                }

                /*
                 * Добавляем пользователя в группу JIRA, пришелшую из AD,
                 * если такая есть
                 */
                int index = userDN.indexOf("OU=") + 3;
                String groupName = "[AD] "
                        + userDN.substring(index, userDN.indexOf(',', index));
                Group group = userUtil.getGroup(groupName);
                if (group != null) {
                    userUtil.addUserToGroup(group, user);
                }

                return user;
            } else {
                return null;
            }
        } finally {
            if (context != null) {
                context.close();
            }
        }
    }

    private String getPhone(String username) throws Exception {
        DirContext context = null;
        try {
            Properties properties = new Properties();
            properties.put(ADDRESS_NAME, ADDRESS_VALUE);
            properties.put(LOGIN_NAME, LOGIN_VALUE);
            properties.put(PASSWORD_NAME, PASSWORD_VALUE);
            properties.put("java.naming.factory.initial",
                    "com.sun.jndi.ldap.LdapCtxFactory");
            context = new InitialDirContext(properties);
            SearchControls controls = new SearchControls();

            controls.setReturningAttributes(new String[] { PHONE_ATTRIBUTE });
            controls.setSearchScope(SearchControls.SUBTREE_SCOPE);

            NamingEnumeration users = context.search(BASEDN, '('
                    + LOGIN_ATTRIBUTE + '=' + username + ')', controls);
            
            if (users != null && users.hasMoreElements()) {
                SearchResult result = (SearchResult) users.next();
                Attribute attribute = result.getAttributes().get(
                        PHONE_ATTRIBUTE);
                String userPhone = null;
                if (attribute != null) {
                    userPhone = ((String) attribute.get()).replace("(", "")
                            .replace(")", "");
                }
                return userPhone;
            } else {
                return null;
            }
        } finally {
            if (context != null) {
                context.close();
            }
        }
    }
}

Компилируем, кладем в $JIRA_HOME/atlassian-jira/WEB-INF/classes, открываем файл $JIRA_HOME/atlassian-jira/WEB-INF/classes/seraph-config.xml и меняем в нем строку

<authenticator class="com.atlassian.jira.security.login.JiraOsUserAuthenticator"/>

на

<authenticator class="RemoteAuthenticator"/>

После этого можно перезапустить JIRA, настроить аутентификацию по Kerberos в своем любимом браузере и проверить работоспособность. Для недоменных компьютеров должно появляться окошко BASIC-авторизации, так что они тоже смогут зайти (если у них есть учетные записи в AD).

Служба

Вдобавок была написана служба, которая периодически синхронизирует пользователей в AD и в JIRA и добавляет текстовую метку к именам тех, которых нет в AD. Вот её код:

import java.util.Map;
import java.util.Properties;
import java.util.Random;

import javax.naming.NamingEnumeration;
import javax.naming.directory.Attribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;

import com.atlassian.configurable.ObjectConfiguration;
import com.atlassian.configurable.ObjectConfigurationException;
import com.atlassian.core.user.preferences.Preferences;
import com.atlassian.jira.ComponentManager;
import com.atlassian.jira.event.user.UserEventType;
import com.atlassian.jira.service.AbstractService;
import com.atlassian.jira.user.preferences.UserPreferencesManager;
import com.atlassian.jira.user.util.UserUtil;
import com.opensymphony.module.propertyset.PropertySet;
import com.opensymphony.user.Group;
import com.opensymphony.user.User;

@SuppressWarnings("deprecation")
public class Importer extends AbstractService implements ObjectConfiguration {

    // Адрес сервера LDAP
    private final String ADDRESS_NAME = "java.naming.provider.url";
    private String ADDRESS_VALUE = "ldap://127.0.0.1:389";

    // DN пользователя для соединения
    private final String LOGIN_NAME = "java.naming.security.principal";
    private String LOGIN_VALUE = "cn=test,ou=users,dc=local,dc=domain";

    // Пароль пользователя
    private final String PASSWORD_NAME = "java.naming.security.credentials";
    private String PASSWORD_VALUE = "password";

    // Базовый DN
    private final String BASE_NAME = "baseDN";
    private String BASE_VALUE = "ou=users,dc=local,dc=domain";

    // Атрибут логина
    private final String LOGIN_ATTR_NAME = "userLoginAttribute";
    private String LOGIN_ATTR_VALUE = "mail";

    // Атрибут имени (если не указан, равно логину)
    private final String NAME_ATTR_NAME = "userNameAttribute";
    private String NAME_ATTR_VALUE = "displayName";

    // Атрибут почтового адреса (если не указан, равен логин + @ + почтовый
    // домен)
    private final String MAIL_ATTR_NAME = "userMailAttribute";
    private String MAIL_ATTR_VALUE = "mail";

    // Атрибут телефона
    private final String PHONE_ATTR_NAME = "userPhoneAttribute";
    private String PHONE_ATTR_VALUE = "telephoneNumber";

    // Почтовый домен
    private final String DOMAIN_NAME = "userMailDomain";
    private String DOMAIN_VALUE = "local.domain";

    // Фильтр пользователей
    private final String FILTER_NAME = "userFilter";
    private String FILTER_VALUE = "(objectClass=user)";

    // Строка, добавляемая к имени пользователя
    private final String FIRED_STRING_NAME = "firedString";
    private String FIRED_STRING_VALUE = "z_fired";

    @Override
    public void init(PropertySet properties)
            throws ObjectConfigurationException {
        super.init(properties);
        for (Object keyObject : properties.getKeys()) {
            String key = (String) keyObject;
            if (key.equals(ADDRESS_NAME)) {
                ADDRESS_VALUE = properties.getString(key);
            } else if (key.equals(LOGIN_NAME)) {
                LOGIN_VALUE = properties.getString(key);
            } else if (key.equals(PASSWORD_NAME)) {
                PASSWORD_VALUE = properties.getString(key);
            } else if (key.equals(BASE_NAME)) {
                BASE_VALUE = properties.getString(key);
            } else if (key.equals(NAME_ATTR_NAME)) {
                NAME_ATTR_VALUE = properties.getString(key);
            } else if (key.equals(LOGIN_ATTR_NAME)) {
                LOGIN_ATTR_VALUE = properties.getString(key);
            } else if (key.equals(MAIL_ATTR_NAME)) {
                MAIL_ATTR_VALUE = properties.getString(key);
            } else if (key.equals(PHONE_ATTR_NAME)) {
                PHONE_ATTR_VALUE = properties.getString(key);
            } else if (key.equals(DOMAIN_NAME)) {
                DOMAIN_VALUE = properties.getString(key);
            } else if (key.equals(FILTER_NAME)) {
                FILTER_VALUE = properties.getString(key);
            } else {
                FIRED_STRING_VALUE = properties.getString(key);
            }
        }
    }

    @Override
    public ObjectConfiguration getObjectConfiguration()
            throws ObjectConfigurationException {
        return this;
    }

    @Override
    public void run() {
        try {
            importUsers();
            checkUsers();
        } catch (Exception exception) {
            exception.printStackTrace();
        }
    }

    // Синхронизирует пользователей в AD и в JIRA
    private void importUsers() throws Exception {
        DirContext context = null;
        try {
            Properties properties = new Properties();
            properties.put(ADDRESS_NAME, ADDRESS_VALUE);
            properties.put(LOGIN_NAME, LOGIN_VALUE);
            properties.put(PASSWORD_NAME, PASSWORD_VALUE);
            properties.put("java.naming.factory.initial",
                    "com.sun.jndi.ldap.LdapCtxFactory");
            ComponentManager componentManager = ComponentManager.getInstance();
            UserPreferencesManager preferencesManager = componentManager
                    .getUserPreferencesManager();
            UserUtil userUtil = componentManager.getUserUtil();
            context = new InitialDirContext(properties);
            SearchControls controls = new SearchControls();

            controls.setReturningAttributes(new String[] { "distinguishedName",
                    LOGIN_ATTR_VALUE, NAME_ATTR_VALUE, MAIL_ATTR_VALUE,
                    PHONE_ATTR_VALUE });
            controls.setSearchScope(SearchControls.SUBTREE_SCOPE);

            NamingEnumeration users = context.search(BASE_VALUE, FILTER_VALUE,
                    controls);

            while (users != null && users.hasMoreElements()) {
                SearchResult result = (SearchResult) users.next();
                
                Attribute attribute = result.getAttributes().get(
                        LOGIN_ATTR_VALUE);
                if (userUtil.getUser((String) attribute.get()) != null) {
                    // Пользователь уже есть в JIRA
                    continue;
                }
                String login = ((String) attribute.get()).toLowerCase();
                
                attribute = result.getAttributes().get(NAME_ATTR_VALUE);
                String userName;
                if (attribute != null) {
                    userName = (String) attribute.get();
                } else {
                    userName = login;
                }
                
                attribute = result.getAttributes().get(MAIL_ATTR_VALUE);
                String userMail;
                if (attribute != null) {
                    userMail = (String) attribute.get();
                } else {
                    userMail = login.replace(' ', '.') + '@' + DOMAIN_VALUE;
                }
                
                attribute = result.getAttributes().get(PHONE_ATTR_VALUE);
                String userPhone = null;
                if (attribute != null) {
                    userPhone = ((String) attribute.get()).replace("(", "")
                            .replace(")", "");
                }
                
                String userDN = (String) result.getAttributes()
                        .get("distinguishedName").get();
                // Генерируем случайный пароль
                String userPassword = Integer.toHexString(new Random()
                        .nextInt());

                // Создаем пользователя
                User user = userUtil.createUserWithEvent(login, userPassword,
                        userMail, userName, UserEventType.USER_CREATED);
                
                if (userPhone != null) {
                    preferencesManager.getPreferences(user).setString(
                            UserUtil.META_PROPERTY_PREFIX + "mobile", userPhone);
                }

                /*
                 * Добавляем пользователя в группу JIRA,
                 * пришешдшую из AD, если такая есть
                 */
                int index = userDN.indexOf("OU=") + 3;
                String groupName = "[AD] "
                        + userDN.substring(index, userDN.indexOf(',', index));
                Group group = userUtil.getGroup(groupName);
                if (group != null) {
                    userUtil.addUserToGroup(group, user);
                }
            }
        } finally {
            if (context != null) {
                context.close();
            }
        }
    }

    // Добавляет метки к тем пользователям, которых нет в AD
    private void checkUsers() throws Exception {
        DirContext context = null;
        try {
            Properties properties = new Properties();
            properties.put(ADDRESS_NAME, ADDRESS_VALUE);
            properties.put(LOGIN_NAME, LOGIN_VALUE);
            properties.put(PASSWORD_NAME, PASSWORD_VALUE);
            properties.put("java.naming.factory.initial",
                    "com.sun.jndi.ldap.LdapCtxFactory");
            ComponentManager componentManager = ComponentManager.getInstance();
            UserPreferencesManager preferencesManager = componentManager
                    .getUserPreferencesManager();
            UserUtil userUtil = componentManager.getUserUtil();
            context = new InitialDirContext(properties);
            SearchControls controls = new SearchControls();

            controls.setReturningAttributes(new String[] { PHONE_ATTR_VALUE });
            controls.setSearchScope(SearchControls.SUBTREE_SCOPE);

            for (User user : userUtil.getAllUsers()) {
                NamingEnumeration users = context.search(BASE_VALUE, "(&("
                        + LOGIN_ATTR_VALUE + '=' + user.getName() + ')'
                        + FILTER_VALUE + ')', controls);
                
                if (users == null || !users.hasMore()) {
                    // Пользователя нет в AD
                    if (!user.getFullName().startsWith(FIRED_STRING_VALUE)) {
                        user.setFullName(FIRED_STRING_VALUE + ' '
                                + user.getFullName());
                    }
                } else {
                    Preferences preferences = preferencesManager.
                            getPreferences(user);
                    String phone = preferences.getString(
                            UserUtil.META_PROPERTY_PREFIX + "mobile");
                    
                    if (phone == null || phone.isEmpty()) {
                        // У пользователя не заполнено свойство с телефоном
                        try {
                            Attribute attribute = ((SearchResult) users.next())
                                    .getAttributes().get(PHONE_ATTR_VALUE);
                            if (attribute != null) {
                                phone = ((String) attribute.get()).replace("(",
                                        "").replace(")", "");
                            }
                            if (phone != null) {
                                preferences.setString(
                                        UserUtil.META_PROPERTY_PREFIX
                                                + "mobile", phone);
                            }
                        } catch (Exception exception) {
                            exception.printStackTrace(System.err);
                        }
                    }
                }
            }
        } finally {
            if (context != null) {
                context.close();
            }
        }
    }

    @Override
    public boolean allFieldsHidden() {
        return false;
    }

    @Override
    public String getName() {
        return "LDAP Importer";
    }

    @Override
    public String getDescription() {
        return "Раз в день импортирует пользователей из LDAP и меняет имена пользователей, которых нет в LDAP";
    }

    @SuppressWarnings("rawtypes")
    @Override
    public String getDescription(Map params) {
        return "Раз в день импортирует пользователей из LDAP и изменяет имя пользователям, которых нет в LDAP";
    }

    @Override
    public String[] getEnabledFieldKeys() {
        return new String[] { ADDRESS_NAME, LOGIN_NAME, PASSWORD_NAME,
                FILTER_NAME, BASE_NAME, LOGIN_ATTR_NAME, NAME_ATTR_NAME,
                MAIL_ATTR_NAME, PHONE_ATTR_NAME, DOMAIN_NAME, FIRED_STRING_NAME };
    }

    @Override
    public String getFieldDefault(String key)
            throws ObjectConfigurationException {
        if (key.equals(ADDRESS_NAME)) {
            return ADDRESS_VALUE;
        } else if (key.equals(LOGIN_NAME)) {
            return LOGIN_VALUE;
        } else if (key.equals(PASSWORD_NAME)) {
            return PASSWORD_VALUE;
        } else if (key.equals(BASE_NAME)) {
            return BASE_VALUE;
        } else if (key.equals(NAME_ATTR_NAME)) {
            return NAME_ATTR_VALUE;
        } else if (key.equals(LOGIN_ATTR_NAME)) {
            return LOGIN_ATTR_VALUE;
        } else if (key.equals(MAIL_ATTR_NAME)) {
            return MAIL_ATTR_VALUE;
        } else if (key.equals(PHONE_ATTR_NAME)) {
            return PHONE_ATTR_VALUE;
        } else if (key.equals(DOMAIN_NAME)) {
            return DOMAIN_VALUE;
        } else if (key.equals(FILTER_NAME)) {
            return FILTER_VALUE;
        } else {
            return FIRED_STRING_VALUE;
        }
    }

    @Override
    public String getFieldDescription(String key)
            throws ObjectConfigurationException {
        if (key.equals(BASE_NAME)) {
            return "Поиск ведется начиная с этого DN";
        } else if (key.equals(MAIL_ATTR_NAME)) {
            return "Если не указан, почта равна "логин + @ + почтовый домен"";
        } else if (key.equals(FIRED_STRING_NAME)) {
            return "Добавляется к имени пользователя если его нет в LDAP";
        } else if (key.equals(NAME_ATTR_NAME)) {
            return "Если не указан, имя равно логину";
        }
        return "";
    }

    @Override
    public String[] getFieldKeys() {
        return new String[] { ADDRESS_NAME, LOGIN_NAME, PASSWORD_NAME,
                FILTER_NAME, BASE_NAME, LOGIN_ATTR_NAME, NAME_ATTR_NAME,
                MAIL_ATTR_NAME, PHONE_ATTR_NAME, DOMAIN_NAME, FIRED_STRING_NAME };
    }

    @Override
    public String getFieldName(String key) throws ObjectConfigurationException {
        if (key.equals(ADDRESS_NAME)) {
            return "Адрес сервера LDAP";
        } else if (key.equals(LOGIN_NAME)) {
            return "Логин пользователя LDAP";
        } else if (key.equals(PASSWORD_NAME)) {
            return "Пароль пользователя LDAP";
        } else if (key.equals(BASE_NAME)) {
            return "Базовый DN";
        } else if (key.equals(NAME_ATTR_NAME)) {
            return "Атрибут имени пользователя";
        } else if (key.equals(LOGIN_ATTR_NAME)) {
            return "Атрибут логина пользователя";
        } else if (key.equals(MAIL_ATTR_NAME)) {
            return "Атрибут адреса почты";
        } else if (key.equals(PHONE_ATTR_NAME)) {
            return "Атрибут телефона";
        } else if (key.equals(DOMAIN_NAME)) {
            return "Почтовый домен";
        } else if (key.equals(FILTER_NAME)) {
            return "Фильтр пользователей";
        } else {
            return "Метка";
        }
    }

    @Override
    public int getFieldType(String key) throws ObjectConfigurationException {
        return 0;
    }

    @SuppressWarnings("rawtypes")
    @Override
    public Map getFieldValues(String key) throws ObjectConfigurationException {
        return null;
    }

    @SuppressWarnings("rawtypes")
    @Override
    public void init(Map params) {
    }

    @Override
    public boolean isEnabled(String key) {
        return true;
    }

    @Override
    public boolean isI18NValues(String key) {
        return false;
    }
}

Скомпилированный jar-файл (с помощью Jira Plugin SDK) со службой положить в $JIRA_HOME/atlassian-jira/WEB-INF/lib/. Добавить службу можно в разделе Administration → System → Services

На этом всё, надеюсь, информация будет кому-нибудь полезной.

Автор: arinoki

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js