После прочтения первой части могло сложиться впечатление, что встроенных возможностей Keycloak должно быть достаточно для большинства задач. Действительно, LDAP Federation прекрасно решает проблему аутентификации и избавляет от необходимости вручную создавать пользователей. Но опыт эксплуатации показывает, что именно после запуска системы в production начинают появляться требования, которые невозможно реализовать исключительно средствами Keycloak.

Бизнес хочет автоматически назначать роли новым сотрудникам. Информационная безопасность требует немедленно отзывать доступы у уволенных работников. Аудиторы задают вопросы о причинах изменения прав. Платформенные команды хотят хранить правила в Git и управлять ими декларативно.

Всё это приводит к появлению ещё одного компонента архитектуры - собственного reconciliation-сервиса, который будет регулярно сравнивать желаемое и фактическое состояние систем и автоматически приводить их в соответствие.

Именно его мы и построим в этой части.

Проектируем собственный сервис синхронизации

Есть одна очень интересная закономерность в развитии инфраструктуры.

Практически все команды начинают одинаково.

Сначала появляется FreeIPA. Затем подключается LDAP Federation в Keycloak. Пользователи импортируются автоматически, единый вход работает, разработчики довольны, безопасники перестают присылать гневные письма, а DevOps-инженеры впервые за долгое время позволяют себе осторожно произнести:

"Кажется, всё получилось."

А потом проходит несколько месяцев.

В компании появляется ещё один отдел. Затем ещё один. Приходят подрядчики. Начинают действовать разные политики доступа. Появляются требования автоматически назначать роли. Безопасники хотят уведомления в Telegram. Руководители требуют согласования выдачи прав. SIEM просит отправлять события. Аудиторы интересуются историей изменений за последние полгода.

И именно в этот момент выясняется неприятная вещь.

LDAP Federation умеет синхронизировать пользователей.

Но она не умеет управлять жизненным циклом идентификаций.

Поэтому между FreeIPA и Keycloak появляется ещё один компонент.

Собственный сервис синхронизации.

Что мы вообще хотим получить

Очень важно не начать писать код раньше времени.

Это одна из любимых ошибок инженеров.

Кто-нибудь открывает IDE и говорит:

"Сейчас быстренько напишем Python-скрипт."

Через год этот "быстренько" превращается в:

sync_final.py
sync_final_v2.py
sync_new.py
sync_final_real.py
sync_final_real_fixed.py

А запускаться всё это начинает через cron на виртуальной машине, которую никто не обновлял с момента запуска проекта.

Поэтому сначала определим требования.

Наш сервис должен уметь:

  • получать пользователей из FreeIPA;

  • получать группы пользователей;

  • создавать пользователей в Keycloak;

  • обновлять пользовательские атрибуты;

  • отключать удалённых сотрудников;

  • создавать группы;

  • назначать роли;

  • выполнять dry-run;

  • вести аудит;

  • отправлять уведомления;

  • безопасно работать при повторных запусках;

  • запускаться как в Docker, так и в Kubernetes;

  • интегрироваться с GitOps-процессами.

Фактически мы строим небольшой IAM-оркестратор.

Общая архитектура

Архитектурно всё становится несколько интереснее.

Если раньше взаимодействие выглядело так:

FreeIPA
    │
 LDAPS
    │
    ▼
Keycloak
    │
    ▼
Applications

то теперь появляется дополнительный слой логики:

                    FreeIPA
                       │
                   LDAPS
                       │
                       ▼
             +----------------+
             | Sync Service   |
             |    Python      |
             +--------+-------+
                      │
              REST API
                      │
                      ▼
                  Keycloak
                      │
                      ▼
                 Applications

Именно Sync Service становится мозгом всей системы.

FreeIPA продолжает оставаться источником истины.

Keycloak продолжает выступать системой единого входа.

А сервис синхронизации принимает решения.

Почему отдельный сервис - это хорошо

На первый взгляд архитектура становится сложнее.

Появляется новый компонент.

Его нужно:

  • разрабатывать;

  • тестировать;

  • обновлять;

  • мониторить;

  • документировать.

Может показаться, что мы создаём себе лишнюю работу.

Но взамен получаем полный контроль над процессом.

Например, появляется возможность реализовать такую логику:

Если пользователь состоит в группе devops
и является штатным сотрудником,
то назначить:
    - argocd-admin
    - grafana-admin
    - harbor-admin

Или:

Если пользователь является подрядчиком,
то не выдавать доступ в production.

Или:

Если сотрудник отсутствует в FreeIPA,
автоматически заблокировать его в Keycloak.

Подобные вещи практически невозможно реализовать средствами одной только LDAP Federation.

Принцип единственного источника истины

Самое важное правило будущего сервиса выглядит следующим образом:

Sync Service ничего не придумывает самостоятельно.

Это правило спасает огромное количество нервных клеток.

FreeIPA остаётся владельцем данных.

Только там создаются пользователи.

Только там определяются группы.

Только там хранится информация о сотрудниках.

Сервис лишь переносит эти изменения дальше.

Получается следующая цепочка:

HR
 ↓
FreeIPA
 ↓
Sync Service
 ↓
Keycloak
 ↓
Applications

Если сотрудник отсутствует в FreeIPA, значит его не существует.

Если сотрудник состоит в группе devops, значит именно FreeIPA так решила.

Сервис не должен создавать собственную альтернативную реальность.

Как будет выглядеть цикл синхронизации

Каждый запуск сервиса должен проходить одинаковые этапы.

Например:

1. Подключение к FreeIPA
2. Получение пользователей
3. Получение групп
4. Подключение к Keycloak
5. Получение существующих пользователей
6. Сравнение состояний
7. Создание новых пользователей
8. Обновление существующих
9. Синхронизация групп
10. Назначение ролей
11. Блокировка отсутствующих пользователей
12. Формирование аудита
13. Отправка уведомлений
14. Завершение работы

Подобный подход называется reconciliation loop.

Если вы работали с Kubernetes, то наверняка узнаете этот паттерн.

Именно так работают контроллеры Kubernetes.

Они постоянно отвечают на вопрос:

"Соответствует ли фактическое состояние желаемому?"

Мы будем использовать тот же принцип.

Desired State против Current State

Это один из важнейших архитектурных моментов.

Допустим, в FreeIPA находится следующий пользователь:

username: ivanov
groups:
  - devops
  - vpn-users

Это желаемое состояние.

Теперь посмотрим на Keycloak:

username: ivanov
groups:
  - vpn-users

Это текущее состояние.

Сервис должен вычислить разницу:

ivanov отсутствует в группе devops

и привести систему к нужному виду.

В результате получится:

username: ivanov
groups:
  - devops
  - vpn-users

Именно поэтому мы не будем просто копировать данные.

Мы будем постоянно выравнивать состояния систем.

Идемпотентность - главное свойство сервиса

Есть слово, которое звучит очень страшно, но спасает инфраструктуру от катастроф.

Идемпотентность.

Если говорить простыми словами:

Повторный запуск сервиса не должен ломать систему.

Например.

Если пользователь уже существует:

Не создавать его повторно.

Если группа уже создана:

Не создавать её заново.

Если роль уже назначена:

Не выполнять лишних действий.

Иначе через некоторое время логи будут выглядеть так:

Created group devops
Created group devops
Created group devops
Created group devops

а пользователи начнут получать по пять одинаковых уведомлений.

Что делать с удалёнными сотрудниками

Это один из самых сложных вопросов.

Когда пользователь исчезает из FreeIPA, существует несколько вариантов поведения.

Вариант 1. Полное удаление

FreeIPA → удалён пользователь
           ↓
Keycloak → удалить пользователя

Плюсы:

  • чистая база.

Минусы:

  • теряется история;

  • исчезают события аудита.

Вариант 2. Блокировка

FreeIPA → удалён пользователь
           ↓
Keycloak → enabled=false

Плюсы:

  • сохраняется аудит;

  • сохраняются ссылки на события;

  • можно восстановить доступ.

Минусы:

  • база постепенно растёт.

На практике чаще всего используют именно блокировку.

Потому что безопасники любят историю.

Особенно если расследуют инциденты.

Dry-run как обязательное требование

Если бы существовал список функций, которые должны быть в любом сервисе управления доступами, dry-run был бы в первой тройке.

Режим должен отвечать на вопрос:

Что произойдёт, если я сейчас выполню синхронизацию?

Например:

DRY RUN

Будут созданы:
- 3 пользователя

Будут обновлены:
- 12 пользователей

Будут заблокированы:
- 2 пользователя

Будут назначены роли:
- 7 пользователей

Без фактического внесения изменений.

Это позволяет безопасно проверять новые правила.

И очень бодрит перед понедельничным запуском после изменения логики назначения ролей.

Аудит должен быть встроенным

Ещё одна распространённая ошибка:

"Потом прикрутим логирование."

Потом обычно не наступает.

Поэтому аудит нужно закладывать сразу.

Например:

2026-06-15 09:00:12
CREATE_USER
ivanov

2026-06-15 09:00:14
ADD_GROUP
ivanov → devops

2026-06-15 09:00:15
ASSIGN_ROLE
ivanov → grafana-admin

2026-06-15 09:00:16
SEND_NOTIFICATION
Telegram

Через несколько месяцев именно эти записи спасут вас во время очередного вопроса:

А кто вообще выдал ему эти права?

Из чего будет состоять сервис

Чтобы не превратить проект в монолит из трёх тысяч строк Python-кода, сразу разобьём его на логические части.

Наша структура будет выглядеть следующим образом:

ipa-keycloak-sync/
├── sync.py
├── config.yaml
├── requirements.txt
├── freeipa.py
├── keycloak_client.py
├── reconciler.py
├── notifier.py
├── audit.py
├── models.py
└── utils.py

Каждый модуль отвечает только за свою область.

Например:

  • freeipa.py - работа с LDAP;

  • keycloak_client.py - взаимодействие с Keycloak;

  • reconciler.py - сравнение состояний;

  • audit.py - аудит;

  • notifier.py - уведомления.

Это кажется избыточным.

До тех пор, пока количество строк не переваливает за тысячу.

После этого все начинают благодарить автора архитектуры.

Почему Python

На этом этапе обычно начинается священная война языков программирования.

Кто-нибудь предлагает Go.

Кто-нибудь вспоминает Java.

Кто-нибудь говорит, что можно вообще всё написать на Bash.

И именно после таких предложений безопасники начинают задумчиво смотреть на окно.

Мы будем использовать Python по нескольким причинам:

  • зрелые библиотеки LDAP;

  • готовые клиенты Keycloak;

  • высокая скорость разработки;

  • большое количество инженеров, умеющих его читать;

  • отличная интеграция с Kubernetes и DevOps-инструментами.

Да, сервис можно написать на Go.

Да, он будет потреблять меньше памяти.

Но в большинстве организаций выигрыш в производительности не компенсирует увеличение стоимости разработки и поддержки.

Что мы получим в итоге

В результате у нас появится полноценный IAM-контроллер, который будет работать по следующему принципу:

FreeIPA
    ↓
Desired State
    ↓
Sync Service
    ↓
Reconciliation
    ↓
Keycloak
    ↓
Applications

Он будет:

  • автоматически создавать пользователей;

  • обновлять их атрибуты;

  • синхронизировать группы;

  • назначать роли;

  • блокировать уволенных сотрудников;

  • вести аудит;

  • отправлять уведомления;

  • безопасно выполнять повторные запуски.

И самое приятное заключается в том, что вся эта логика будет описана в нескольких сотнях строк понятного Python-кода, а не спрятана в десятках экранов административных интерфейсов, где никто уже не помнит, кто и зачем поставил ту самую галочку три года назад.

Теперь, когда архитектура будущего сервиса окончательно сформировалась, можно переходить к его практической реализации и начинать писать production-ready Python sync service, который превратит всю эту теорию в реально работающий механизм управления идентификацией.

Production-ready Python sync service

В любой статье про интеграцию FreeIPA и Keycloak рано или поздно появляется тот самый Python-скрипт из двухсот строк, который "полностью решает проблему". Обычно он выглядит примерно так:

ldap_users = get_users()
kc_users = get_users()

for user in ldap_users:
    if user not in kc_users:
        create_user(user)

Первые несколько недель такой код действительно работает. Потом появляется первая ошибка LDAP. Затем безопасники просят аудит. Затем выясняется, что сотрудника забыли отключить после увольнения. Потом возникает необходимость уведомлять Telegram. Затем приходит требование реализовать dry-run.

И внезапно этот "небольшой скрипт" превращается в трёхтысячный монолит под названием:

sync.py
sync_final.py
sync_final_v2.py
sync_real.py
sync_final_real.py
sync_final_real_fixed.py

После чего все начинают бояться к нему прикасаться.

Поэтому мы сразу будем писать сервис так, как его обычно пишут платформенные команды.

Структура проекта

Наш проект будет выглядеть следующим образом:

ipa-keycloak-sync/
├── requirements.txt
├── config.yaml
├── sync.py
├── models.py
├── exceptions.py
├── freeipa.py
├── keycloak_client.py
├── reconciler.py
├── audit.py
├── notifier.py
└── utils.py

Каждый файл отвечает только за свою задачу.

Именно это позволит нам развивать сервис без превращения его в инфраструктурного Франкенштейна.

requirements.txt

Начнём с зависимостей.

python-keycloak==4.3.0
ldap3==2.9.1
PyYAML==6.0.2
requests==2.32.3
tenacity==9.0.0

Разберёмся, зачем нужна каждая библиотека.

python-keycloak

Официальный клиент Keycloak.

Позволяет работать с пользователями, группами и ролями через REST API.

ldap3

Современная библиотека для работы с LDAP.

Используется для взаимодействия с FreeIPA.

PyYAML

Загрузка конфигурационных файлов.

requests

Интеграция с Telegram и внешними сервисами.

tenacity

Механизм повторных попыток.

Именно он спасает сервис во время кратковременных проблем с сетью или временной недоступности Keycloak.

config.yaml

Конфигурация хранится отдельно от кода.

freeipa:
  host: ipa.example.local
  port: 636
  use_ssl: true

  bind_dn: uid=sync,cn=users,cn=accounts,dc=example,dc=local
  password: SuperPassword

  base_dn: cn=users,cn=accounts,dc=example,dc=local

keycloak:
  url: https://keycloak.example.local/
  realm: company

  username: sync
  password: SuperPassword

sync:
  dry_run: false
  verify_ssl: true

  default_password: TempPassword123!

  managed_groups:
    - devops
    - developers
    - admins

telegram:
  enabled: false
  token: ""
  chat_id: ""

Обрати внимание.

Мы сознательно не храним конфигурацию внутри кода.

Иначе любое изменение LDAP DN превращается в полноценный релиз приложения.

models.py

Модели позволяют отказаться от бесконечной работы со словарями.

from dataclasses import dataclass, field

@dataclass
class User:
    username: str
    email: str
    first_name: str
    last_name: str
    groups: list[str] = field(default_factory=list)
    enabled: bool = True

Теперь вместо:

user["email"]

можно писать:

user.email

Подобные мелочи существенно упрощают поддержку.

exceptions.py

Выделим собственные исключения.

class SyncError(Exception):
    pass

class FreeIPAConnectionError(SyncError):
    pass

class KeycloakConnectionError(SyncError):
    pass

class ReconciliationError(SyncError):
    pass

Даже если сейчас они кажутся избыточными, позже это позволит гораздо лучше обрабатывать ошибки.

utils.py

Начнём собирать вспомогательные функции.

import yaml

def load_config(path="config.yaml"):
    with open(path) as f:
        return yaml.safe_load(f)

def normalize_group(group_dn: str) -> str:
    return (
        group_dn.split(",")[0]
        .replace("cn=", "")
        .strip()
    )

Вторая функция кажется незначительной.

Но именно она избавляет от постоянного копирования логики разбора LDAP-групп.

audit.py

Любой сервис управления доступами обязан вести аудит.

import logging

logger = logging.getLogger("audit")

def audit(event, details):
    logger.info(
        "[AUDIT] %s: %s",
        event,
        details,
    )

Пример использования:

audit(
    "USER_CREATED",
    "ivanov"
)

Лог:

[AUDIT] USER_CREATED: ivanov

Через полгода именно эти записи спасут тебя во время очередного вопроса безопасников:

А кто выдал ему права администратора?

На этом месте у нас уже готов фундамент сервиса: структура проекта, конфигурация, модели, обработка ошибок и аудит.

Дальше начинается самое интересное - работа с LDAP и FreeIPA, где мы реализуем полноценный клиент для получения пользователей и групп из корпоративного каталога, включая обработку ошибок и повторные попытки подключения. Именно с этого момента наш сервис перестанет быть просто набором файлов и начнёт превращаться в настоящий IAM-контроллер.

Продолжим строить наш сервис. До этого момента мы подготовили фундамент проекта. Теперь настало время реализовать две самые важные части всей системы - взаимодействие с FreeIPA и Keycloak. Именно здесь рождается тот самый reconciliation loop, который будет отвечать на вопрос:

"Соответствует ли текущее состояние Keycloak тому, что описано в FreeIPA?"

Именно этот подход используется внутри Kubernetes-контроллеров. Вместо Pod'ов и Deployment'ов мы будем управлять пользователями, группами и ролями.

freeipa.py

Начнём с клиента для работы с FreeIPA.

Его задача максимально проста:

  • установить соединение с LDAP;

  • получить список пользователей;

  • получить список групп пользователя;

  • преобразовать LDAP-записи в наши модели;

  • обработать возможные ошибки.

import logging

from ldap3 import (
    Server,
    Connection,
    ALL,
)
from ldap3.core.exceptions import LDAPException

from models import User
from exceptions import FreeIPAConnectionError
from utils import normalize_group

logger = logging.getLogger(__name__)

class FreeIPAClient:
    def __init__(self, config):
        self.config = config
        self.connection = None

    def connect(self):
        try:
            server = Server(
                self.config["host"],
                port=self.config["port"],
                use_ssl=self.config["use_ssl"],
                get_info=ALL,
            )

            self.connection = Connection(
                server,
                user=self.config["bind_dn"],
                password=self.config["password"],
                auto_bind=True,
            )

            logger.info("Connected to FreeIPA")

        except LDAPException as exc:
            raise FreeIPAConnectionError(str(exc))

    def get_users(self):
        self.connection.search(
            search_base=self.config["base_dn"],
            search_filter="(objectClass=person)",
            attributes=[
                "uid",
                "mail",
                "givenName",
                "sn",
                "memberOf",
            ],
        )

        users = []

        for entry in self.connection.entries:
            groups = []

            if "memberOf" in entry:
                groups = [
                    normalize_group(group)
                    for group in entry.memberOf
                ]

            users.append(
                User(
                    username=str(entry.uid),
                    email=str(entry.mail),
                    first_name=str(entry.givenName),
                    last_name=str(entry.sn),
                    groups=groups,
                    enabled=True,
                )
            )

        logger.info(
            "Loaded %s users from FreeIPA",
            len(users),
        )

        return users

На первый взгляд здесь нет ничего сложного.

Но именно этот код становится нашим источником желаемого состояния.

Другими словами:

FreeIPA = Desired State

Именно отсюда сервис узнаёт, кто должен существовать, в каких группах состоять и иметь ли вообще доступ к инфраструктуре.

Почему мы используем objectClass=person

Обрати внимание на фильтр:

search_filter="(objectClass=person)"

Он используется практически во всех примерах.

Но в production иногда приходится делать более сложные конструкции.

Например:

search_filter=(
    "(&(objectClass=person)"
    "(uid=*)"
    "(!(nsaccountlock=TRUE)))"
)

Подобный фильтр сразу исключает заблокированных пользователей FreeIPA.

Это позволяет сократить количество лишних действий при синхронизации.

keycloak_client.py

Теперь реализуем клиента для работы с Keycloak.

Его обязанности:

  • получать пользователей;

  • создавать новых;

  • обновлять существующих;

  • блокировать удалённых;

  • работать с группами.

import logging

from keycloak import KeycloakAdmin
from keycloak.exceptions import (
    KeycloakAuthenticationError,
)

from exceptions import KeycloakConnectionError

logger = logging.getLogger(__name__)

class KeycloakClient:
    def __init__(self, config, verify_ssl):
        self.config = config
        self.verify_ssl = verify_ssl
        self.admin = None

    def connect(self):
        try:
            self.admin = KeycloakAdmin(
                server_url=self.config["url"],
                username=self.config["username"],
                password=self.config["password"],
                realm_name=self.config["realm"],
                verify=self.verify_ssl,
            )

            logger.info("Connected to Keycloak")

        except KeycloakAuthenticationError as exc:
            raise KeycloakConnectionError(str(exc))

    def get_users(self):
        return self.admin.get_users()

    def create_user(self, user):
        payload = {
            "username": user.username,
            "email": user.email,
            "enabled": True,
            "firstName": user.first_name,
            "lastName": user.last_name,
        }

        return self.admin.create_user(
            payload,
            exist_ok=True,
        )

    def update_user(self, user_id, user):
        payload = {
            "email": user.email,
            "firstName": user.first_name,
            "lastName": user.last_name,
            "enabled": user.enabled,
        }

        self.admin.update_user(
            user_id=user_id,
            payload=payload,
        )

    def disable_user(self, user_id):
        self.admin.update_user(
            user_id=user_id,
            payload={
                "enabled": False,
            },
        )

    def get_groups(self):
        return self.admin.get_groups()

    def create_group(self, group_name):
        return self.admin.create_group(
            {
                "name": group_name,
            }
        )

    def add_user_to_group(
        self,
        user_id,
        group_id,
    ):
        self.admin.group_user_add(
            user_id=user_id,
            group_id=group_id,
        )

Почему мы блокируем, а не удаляем

Наиболее интересный момент находится здесь:

def disable_user(...)

Многие инженеры первым делом реализуют именно удаление.

Например:

admin.delete_user(user_id)

Но это плохая идея.

Почему?

Потому что вместе с пользователем исчезают:

  • события аудита;

  • ссылки на прошлые действия;

  • история входов;

  • возможность быстрого восстановления.

В production чаще используют именно блокировку:

enabled=False

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

А администраторам - случайно не удалить половину компании после ошибки в фильтре LDAP.

Работа с группами

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

Получаем существующие группы:

groups = keycloak.get_groups()

Формируем индекс:

group_index = {
    group["name"]: group
    for group in groups
}

Почему именно словарь?

Потому что поиск:

group_index["devops"]

работает значительно быстрее, чем:

for group in groups:
    ...

Разница особенно заметна после появления нескольких сотен групп.

Почему не использовать прямое сопоставление LDAP-групп

У читателя может возникнуть вопрос:

Зачем вообще всё это? Почему не использовать встроенный Group Mapper?

Причина проста.

Встроенный механизм работает прекрасно до тех пор, пока бизнес-логика остаётся простой.

Но как только появляются требования вроде:

  • подрядчикам нельзя выдавать production-доступ;

  • DevOps получают дополнительные роли;

  • безопасников нужно уведомлять через Telegram;

  • изменения должны попадать в SIEM,

приходится переносить управление в собственный код.

Именно поэтому дальше мы будем строить reconciliation-механизм, который сравнивает FreeIPA и Keycloak, вычисляет различия и автоматически приводит систему к нужному состоянию.

Именно там наш сервис окончательно превратится из "ещё одного Python-скрипта" в полноценный IAM-контроллер уровня production.

notifier.py

Как показывает практика, инфраструктура в СНГ официально считается не production, если она не умеет отправлять уведомления в Telegram.

Шутки шутками, но возможность автоматически уведомлять ответственных сотрудников действительно оказывается крайне полезной.

Например:

  • пользователь получил административные права;

  • сотрудник был заблокирован;

  • произошла ошибка синхронизации;

  • сервис не смог подключиться к FreeIPA;

  • произошли массовые изменения доступов.

Реализуем простой модуль уведомлений.

import logging
import requests

logger = logging.getLogger(__name__)

class TelegramNotifier:
    def __init__(
        self,
        enabled,
        token,
        chat_id,
    ):
        self.enabled = enabled
        self.token = token
        self.chat_id = chat_id

    def send(self, text):
        if not self.enabled:
            return

        try:
            requests.post(
                f"https://api.telegram.org/bot{self.token}/sendMessage",
                json={
                    "chat_id": self.chat_id,
                    "text": text,
                },
                timeout=10,
            )

        except Exception as exc:
            logger.exception(
                "Failed to send Telegram notification: %s",
                exc,
            )

Использование:

notifier.send(
    "User ivanov was disabled"
)

Подобный механизм легко расширяется под:

  • Slack;

  • Microsoft Teams;

  • Jira;

  • ServiceNow;

  • SIEM.

reconciler.py

А вот теперь начинается сердце всей системы.

Именно здесь реализуется reconciliation loop.

Он отвечает на вопрос:

Соответствует ли текущее состояние Keycloak желаемому состоянию из FreeIPA?

Если нет - исправляем.

import logging

from audit import audit

logger = logging.getLogger(__name__)

class Reconciler:
    def __init__(
        self,
        freeipa_users,
        keycloak,
        managed_groups,
        dry_run=False,
    ):
        self.freeipa_users = freeipa_users
        self.keycloak = keycloak
        self.managed_groups = managed_groups
        self.dry_run = dry_run

Индексация пользователей

Первым делом строим индекс пользователей Keycloak.

    def get_existing_users(self):
        users = self.keycloak.get_users()

        return {
            user["username"]: user
            for user in users
        }

Индекс позволяет быстро находить пользователей.

Особенно если их несколько тысяч.

Создание пользователей

    def create_missing_users(
        self,
        existing_users,
    ):
        for user in self.freeipa_users:

            if user.username in existing_users:
                continue

            if self.dry_run:
                logger.info(
                    "DRY RUN: create %s",
                    user.username,
                )
                continue

            self.keycloak.create_user(user)

            audit(
                "USER_CREATED",
                user.username,
            )

            logger.info(
                "Created user %s",
                user.username,
            )

Именно здесь проявляется идемпотентность.

Повторный запуск не создаст пользователей заново.

Обновление пользователей

Пользователь уже существует.

Но его данные могли измениться.

Например:

  • изменилась почта;

  • поменялась фамилия;

  • сотрудник сменил имя.

    def update_existing_users(
        self,
        existing_users,
    ):
        for user in self.freeipa_users:

            if user.username not in existing_users:
                continue

            kc_user = existing_users[user.username]

            payload_changed = (
                kc_user.get("email") != user.email
                or kc_user.get("firstName") != user.first_name
                or kc_user.get("lastName") != user.last_name
            )

            if not payload_changed:
                continue

            if self.dry_run:
                logger.info(
                    "DRY RUN: update %s",
                    user.username,
                )
                continue

            self.keycloak.update_user(
                kc_user["id"],
                user,
            )

            audit(
                "USER_UPDATED",
                user.username,
            )

Блокировка уволенных сотрудников

Это одна из самых любимых функций специалистов ИБ.

Если пользователь исчез из FreeIPA, его необходимо отключить.

    def disable_removed_users(
        self,
        existing_users,
    ):
        ldap_usernames = {
            user.username
            for user in self.freeipa_users
        }

        for username, kc_user in existing_users.items():

            if username in ldap_usernames:
                continue

            if self.dry_run:
                logger.info(
                    "DRY RUN: disable %s",
                    username,
                )
                continue

            self.keycloak.disable_user(
                kc_user["id"],
            )

            audit(
                "USER_DISABLED",
                username,
            )

            logger.warning(
                "Disabled user %s",
                username,
            )

Почему именно блокировка?

Потому что аудит важнее чистоты базы.

Удалить пользователя можно всегда.

Вернуть удалённую историю входов значительно сложнее.

Синхронизация групп

Теперь переходим к группам.

Получаем существующие группы Keycloak:

    def sync_groups(
        self,
        existing_users,
    ):
        groups = self.keycloak.get_groups()

        group_index = {
            group["name"]: group
            for group in groups
        }

Проходим по пользователям:

        for user in self.freeipa_users:

            kc_user = existing_users.get(
                user.username
            )

            if not kc_user:
                continue

            user_id = kc_user["id"]

            for group_name in user.groups:

                if (
                    group_name
                    not in self.managed_groups
                ):
                    continue

Создаём отсутствующие группы:

                if group_name not in group_index:

                    if self.dry_run:
                        logger.info(
                            "DRY RUN: create group %s",
                            group_name,
                        )
                        continue

                    group_id = (
                        self.keycloak.create_group(
                            group_name
                        )
                    )

                    group_index[group_name] = {
                        "id": group_id,
                    }

                    audit(
                        "GROUP_CREATED",
                        group_name,
                    )

Добавляем пользователя:

                if self.dry_run:
                    logger.info(
                        "DRY RUN: add %s to %s",
                        user.username,
                        group_name,
                    )
                    continue

                self.keycloak.add_user_to_group(
                    user_id,
                    group_index[group_name]["id"],
                )

                audit(
                    "GROUP_ASSIGNED",
                    f"{user.username}:{group_name}",
                )

Запуск reconciliation

Теперь объединим всё вместе.

    def reconcile(self):
        existing_users = (
            self.get_existing_users()
        )

        self.create_missing_users(
            existing_users,
        )

        existing_users = (
            self.get_existing_users()
        )

        self.update_existing_users(
            existing_users,
        )

        self.sync_groups(
            existing_users,
        )

        self.disable_removed_users(
            existing_users,
        )

Именно этот метод и является мозгом всего сервиса.

Он последовательно приводит систему к желаемому состоянию.

Почти как Kubernetes Controller.

Retry-механизмы и устойчивость к отказам

Если посмотреть на любой production-сервис, становится очевидно, что большая часть кода посвящена вовсе не бизнес-логике. Основная задача заключается в том, чтобы переживать временные проблемы окружающего мира.

Keycloak может перезапускаться во время обновления.

FreeIPA может быть недоступен несколько секунд из-за переключения реплики.

Сеть может потерять несколько пакетов.

Если завершать работу после первой ошибки, одна неудачная попытка подключения способна остановить весь процесс синхронизации.

Именно поэтому мы добавим повторные попытки.

Добавляем retry в freeipa.py

Импортируем декораторы:

from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
)

Обновим подключение:

    @retry(
        stop=stop_after_attempt(5),
        wait=wait_exponential(
            multiplier=1,
            min=1,
            max=30,
        ),
        reraise=True,
    )
    def connect(self):
        try:
            server = Server(
                self.config["host"],
                port=self.config["port"],
                use_ssl=self.config["use_ssl"],
                get_info=ALL,
            )

            self.connection = Connection(
                server,
                user=self.config["bind_dn"],
                password=self.config["password"],
                auto_bind=True,
            )

            logger.info(
                "Connected to FreeIPA"
            )

        except LDAPException as exc:
            raise FreeIPAConnectionError(
                str(exc)
            )

Теперь сервис будет пытаться подключиться несколько раз.

Интервалы ожидания будут следующими:

1 секунда
2 секунды
4 секунды
8 секунд
16 секунд

Только после этого ошибка будет считаться окончательной.

Добавляем retry в keycloak_client.py

Точно так же поступим с Keycloak.

from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
)

Подключение:

    @retry(
        stop=stop_after_attempt(5),
        wait=wait_exponential(
            multiplier=1,
            min=1,
            max=30,
        ),
        reraise=True,
    )
    def connect(self):
        try:
            self.admin = KeycloakAdmin(
                server_url=self.config["url"],
                username=self.config["username"],
                password=self.config["password"],
                realm_name=self.config["realm"],
                verify=self.verify_ssl,
            )

            logger.info(
                "Connected to Keycloak"
            )

        except Exception as exc:
            raise KeycloakConnectionError(
                str(exc)
            )

sync.py

Теперь соберём все компоненты вместе.

Именно этот файл станет точкой входа нашего приложения.

import argparse
import logging
import sys

from audit import audit
from freeipa import FreeIPAClient
from keycloak_client import KeycloakClient
from notifier import TelegramNotifier
from reconciler import Reconciler
from utils import load_config

Настройка логирования

Логи должны быть одинаковыми во всех средах.

logging.basicConfig(
    level=logging.INFO,
    format=(
        "%(asctime)s "
        "%(levelname)s "
        "%(name)s "
        "%(message)s"
    ),
)

logger = logging.getLogger(
    "ipa-keycloak-sync"
)

Пример лога:

2026-06-15 11:05:23 INFO ipa-keycloak-sync Sync started

Разбор аргументов

Поддержим dry-run через командную строку.

def parse_args():
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show changes only",
    )

    return parser.parse_args()

Основная функция

def main():
    args = parse_args()

    config = load_config()

    dry_run = (
        args.dry_run
        or config["sync"]["dry_run"]
    )

    logger.info(
        "Sync started"
    )

    logger.info(
        "Dry-run mode: %s",
        dry_run,
    )

Инициализация уведомлений

    notifier = TelegramNotifier(
        enabled=config["telegram"]["enabled"],
        token=config["telegram"]["token"],
        chat_id=config["telegram"]["chat_id"],
    )

Подключение к FreeIPA

    freeipa = FreeIPAClient(
        config["freeipa"]
    )

    freeipa.connect()

    freeipa_users = (
        freeipa.get_users()
    )

Подключение к Keycloak

    keycloak = KeycloakClient(
        config["keycloak"],
        verify_ssl=config["sync"][
            "verify_ssl"
        ],
    )

    keycloak.connect()

Запуск reconciliation

    reconciler = Reconciler(
        freeipa_users=freeipa_users,
        keycloak=keycloak,
        managed_groups=config[
            "sync"
        ]["managed_groups"],
        dry_run=dry_run,
    )

    reconciler.reconcile()

Завершение работы

    audit(
        "SYNC_COMPLETED",
        "success",
    )

    notifier.send(
        "✅ IPA-Keycloak sync completed"
    )

    logger.info(
        "Sync completed"
    )

Обработка ошибок

Никогда не оставляйте главный цикл без обработки исключений.

if __name__ == "__main__":
    try:
        main()

    except Exception as exc:
        logger.exception(
            "Sync failed: %s",
            exc,
        )

        try:
            config = load_config()

            notifier = TelegramNotifier(
                enabled=config[
                    "telegram"
                ]["enabled"],
                token=config[
                    "telegram"
                ]["token"],
                chat_id=config[
                    "telegram"
                ]["chat_id"],
            )

            notifier.send(
                f"❌ Sync failed: {exc}"
            )

        except Exception:
            pass

        sys.exit(1)

Пример запуска

Обычный режим:

python sync.py

Dry-run:

python sync.py --dry-run

Пример вывода:

2026-06-15 11:00:01 INFO Sync started
2026-06-15 11:00:02 INFO Connected to FreeIPA
2026-06-15 11:00:02 INFO Loaded 127 users
2026-06-15 11:00:03 INFO Connected to Keycloak
2026-06-15 11:00:04 INFO Created user petrov
2026-06-15 11:00:05 INFO Updated user ivanov
2026-06-15 11:00:05 WARNING Disabled user sidorov
2026-06-15 11:00:06 INFO Sync completed

Dry-run:

DRY RUN: create petrov
DRY RUN: update ivanov
DRY RUN: disable sidorov

Что получилось в итоге

Если посмотреть на итоговую реализацию, то окажется, что мы построили не просто Python-скрипт, а полноценный IAM-контроллер.

Он умеет:

  • подключаться к FreeIPA;

  • получать пользователей и группы;

  • подключаться к Keycloak;

  • вычислять различия между системами;

  • создавать новых пользователей;

  • обновлять существующих;

  • синхронизировать группы;

  • блокировать уволенных сотрудников;

  • безопасно переживать временные сбои;

  • работать в режиме dry-run;

  • вести аудит;

  • отправлять уведомления;

  • поддерживать повторные запуски без побочных эффектов.

Фактически мы реализовали тот же паттерн reconciliation loop, который лежит в основе Kubernetes. Только вместо Pod'ов и Deployment'ов мы управляем самым ценным активом инфраструктуры - доступами пользователей.

И здесь возникает любопытный парадокс. Большинство инженеров начинают с мысли:

"Нам нужен небольшой скрипт для синхронизации."

А заканчивают созданием отдельного инфраструктурного сервиса со своей архитектурой, аудитом и жизненным циклом.

И это абсолютно нормально.

Потому что управление идентификацией перестаёт быть задачей "скопировать пользователя из LDAP". Оно превращается в один из ключевых процессов платформенной инженерии.

Dockerfile и контейнеризация сервиса

Есть один любопытный момент в развитии любого внутреннего инструмента. В самом начале он живёт в домашнем каталоге инженера, запускается командой python sync.py, использует локальный config.yaml и прекрасно работает. Потом появляется второй сервер. Затем Kubernetes. Затем возникает необходимость запускать сервис на тестовом стенде. Потом безопасники просят воспроизводимость. Потом приходит новый сотрудник и задаёт вопрос:

А как вообще это запускать?

И именно в этот момент становится понятно, что любая инфраструктурная утилита, претендующая на статус production-сервиса, должна быть контейнеризирована.

Контейнеризация даёт нам несколько очень важных преимуществ:

  • воспроизводимость среды выполнения;

  • предсказуемость зависимостей;

  • переносимость между окружениями;

  • удобство запуска в Docker и Kubernetes;

  • единый процесс доставки;

  • возможность интеграции с CI/CD;

  • контроль версий образов.

Фактически контейнер становится единицей поставки нашего сервиса.

Почему нельзя просто запускать Python напрямую

В небольших инфраструктурах очень часто можно встретить следующую картину:

root@sync-server:/opt/sync# python sync.py

Или ещё лучше:

*/15 * * * * cd /opt/sync && python sync.py

Первое время всё работает замечательно.

Потом происходит обновление Python.

Потом ломается виртуальное окружение.

Потом один сервер использует Python 3.10, второй - Python 3.12.

Потом библиотека python-keycloak обновляется на одном узле, но не обновляется на другом.

А потом начинается классическое:

У меня работает.

А у меня нет.

Контейнеризация позволяет избавиться от подобных приключений.

Требования к образу

Прежде чем писать Dockerfile, определим требования.

Наш контейнер должен:

  • использовать минимальный базовый образ;

  • содержать только необходимые зависимости;

  • запускаться от непривилегированного пользователя;

  • поддерживать конфигурацию через volume и Secret;

  • быстро собираться;

  • быстро запускаться;

  • не содержать лишних утилит;

  • быть пригодным для запуска в Kubernetes.

Кроме того, желательно минимизировать поверхность атаки.

Потому что сервис управления идентификацией обладает доступом сразу к двум критически важным системам:

  • FreeIPA;

  • Keycloak.

Выбор базового образа

Самый простой вариант выглядит следующим образом:

FROM python:3.13

Он работает.

Но есть проблема.

Полноценный образ Python содержит огромное количество компонентов, которые нашему сервису никогда не понадобятся.

Например:

  • инструменты сборки;

  • документацию;

  • дополнительные системные пакеты;

  • временные файлы.

Поэтому используем slim-образ:

FROM python:3.13-slim

Он значительно меньше.

Для сравнения:

Образ Размер
python:3.13 ~1 ГБ
python:3.13-slim ~150 МБ

Разница весьма существенная.

Особенно когда образ необходимо регулярно доставлять в Kubernetes-кластер.

Первый вариант Dockerfile

Минимальная рабочая версия выглядит следующим образом:

FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install -r requirements.txt

COPY . .

CMD ["python", "sync.py"]

Она полностью работоспособна.

Но для production её недостаточно.

Почему этого мало

У подобного Dockerfile есть несколько проблем.

Во-первых, контейнер запускается от root.

Во-вторых, отсутствует отключение кеша pip.

В-третьих, не оптимизирован порядок слоёв.

В-четвёртых, отсутствуют настройки Python.

В-пятых, невозможно быстро понять, кто именно собрал образ и какую версию приложения он содержит.

Поэтому улучшим его.

Production Dockerfile

Ниже приведён вариант, который уже можно использовать в реальной эксплуатации.

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=1

WORKDIR /app

RUN groupadd -r sync && \
    useradd -r -g sync sync

COPY requirements.txt .

RUN pip install \
    --no-cache-dir \
    -r requirements.txt

COPY . .

RUN chown -R sync:sync /app

USER sync

CMD ["python", "sync.py"]

На первый взгляд изменений немного.

Но каждая строка появилась здесь не случайно.

Настройки Python

Отключаем создание pyc-файлов:

ENV PYTHONDONTWRITEBYTECODE=1

В контейнере они практически бесполезны.

Зато создают лишний шум.

Включаем немедленный вывод логов:

ENV PYTHONUNBUFFERED=1

Без этой настройки логи могут задерживаться в буфере.

А когда сервис внезапно перестал работать, ждать появления сообщений в логах никто не любит.

Отключаем кеш pip:

ENV PIP_NO_CACHE_DIR=1

Это уменьшает итоговый размер образа.

Непривилегированный пользователь

Создаём отдельного пользователя:

RUN groupadd -r sync && \
    useradd -r -g sync sync

После чего переключаемся на него:

USER sync

Почему это важно?

Если злоумышленник получит возможность выполнить код внутри контейнера, запуск от root существенно увеличивает потенциальный ущерб.

Запуск от непривилегированного пользователя считается хорошей практикой практически для всех контейнеров.

Особенно для сервисов, работающих с системами аутентификации.

Оптимизация слоёв

Обрати внимание на порядок операций.

Сначала копируем зависимости:

COPY requirements.txt .

Устанавливаем их:

RUN pip install ...

И только потом копируем исходный код:

COPY . .

Почему?

Потому что Docker использует кеширование слоёв.

Если изменился только Python-код, повторная установка библиотек не потребуется.

Это значительно ускоряет сборку.

Добавляем метаданные образа

Очень полезная практика - сохранять информацию о версии.

Например:

ARG VERSION=unknown
ARG BUILD_DATE=unknown

LABEL org.opencontainers.image.version=$VERSION
LABEL org.opencontainers.image.created=$BUILD_DATE

Теперь можно быстро определить:

  • когда был собран образ;

  • какая версия используется;

  • соответствует ли она ожидаемой.

Это особенно удобно при расследовании инцидентов.

.dockerignore

Ещё один файл, о котором часто забывают.

Создадим .dockerignore:

.git
.gitignore
venv
__pycache__
*.pyc
*.pyo
.env
.idea
.vscode
tests
README.md

Без него Docker будет отправлять в контекст сборки всё подряд.

Включая:

  • локальные виртуальные окружения;

  • настройки IDE;

  • кеши Python;

  • секреты.

Последний пункт особенно неприятен.

Сборка образа

Собираем контейнер:

docker build \
    -t ipa-keycloak-sync:1.0.0 \
    .

Если используется GitLab CI или Jenkins, обычно применяют следующую схему:

docker build \
    -t registry.local/ipa-keycloak-sync:1.0.0 \
    -t registry.local/ipa-keycloak-sync:latest \
    .

После чего образ публикуется в реестр.

Проверяем размер образа

Посмотреть итоговый размер можно так:

docker images

Например:

REPOSITORY            TAG      SIZE
ipa-keycloak-sync     1.0.0    176MB

Для Python-сервиса подобный размер считается вполне приемлемым.

Запускаем контейнер

Самый простой запуск:

docker run \
    --rm \
    -v $(pwd)/config.yaml:/app/config.yaml \
    ipa-keycloak-sync:1.0.0

Dry-run:

docker run \
    --rm \
    -v $(pwd)/config.yaml:/app/config.yaml \
    ipa-keycloak-sync:1.0.0 \
    python sync.py --dry-run

Пример вывода:

2026-06-15 11:00:01 INFO Sync started
2026-06-15 11:00:02 INFO Connected to FreeIPA
2026-06-15 11:00:03 INFO Connected to Keycloak
2026-06-15 11:00:04 INFO Sync completed

Multi-stage сборка - нужна ли она?

Часто можно встретить рекомендации использовать multi-stage Dockerfile.

Например:

FROM python:3.13 AS builder
...
FROM python:3.13-slim
...

Для нашего сервиса в этом нет особого смысла.

Мы не компилируем бинарные файлы.

Не собираем Go-приложение.

Не используем тяжёлые инструменты сборки.

Поэтому выигрыш будет минимальным.

Иногда простое решение действительно оказывается лучшим.

Сканирование образов

Раз уж сервис управляет доступами, стоит регулярно проверять контейнеры на уязвимости.

Например:

trivy image ipa-keycloak-sync:1.0.0

или:

grype ipa-keycloak-sync:1.0.0

Это позволяет выявлять устаревшие зависимости и известные проблемы безопасности.

Особенно полезно встроить подобные проверки в CI/CD.

Production best practices

За годы эксплуатации контейнеров сформировался достаточно универсальный набор рекомендаций:

  • не запускайте контейнеры от root;

  • используйте slim-образы;

  • отключайте кеш pip;

  • используйте .dockerignore;

  • подписывайте образы тегами версий;

  • регулярно обновляйте базовые образы;

  • сканируйте контейнеры на уязвимости;

  • не храните секреты внутри образа;

  • проверяйте итоговый размер образов;

  • документируйте процесс сборки.

Особенно предпоследний пункт.

Потому что нет ничего более неприятного, чем обнаружить, что пароль от FreeIPA оказался запечён внутрь Docker-образа и уже несколько месяцев лежит в корпоративном реестре контейнеров.

Что мы получили в итоге

После контейнеризации наш сервис перестал быть просто набором Python-файлов.

Теперь он представляет собой полноценный артефакт поставки, который можно:

  • запускать локально;

  • использовать в Docker Compose;

  • публиковать в контейнерный реестр;

  • разворачивать в Kubernetes;

  • интегрировать в GitLab CI, Jenkins и другие системы доставки.

А самое главное - теперь он запускается одинаково в любой среде.

Больше никаких:

"У меня установлен другой Python."

"А у меня другая версия библиотеки."

"На тестовом сервере всё работает."

Контейнер становится единым способом доставки нашего IAM-контроллера.

И именно это подводит нас к следующему шагу. Ведь если сервис уже упакован в контейнер, возникает вполне логичный вопрос:

А почему бы не автоматизировать его запуск полностью?

Поэтому в следующем разделе мы развернём наш сервис через Docker Compose и подготовим его к полноценной эксплуатации в составе всей архитектуры FreeIPA и Keycloak.

Docker Compose для sync-сервиса

После того как наш сервис получил полноценный Dockerfile, возникает вполне логичный вопрос:

А как теперь всё это запускать?

Да, можно выполнять длинную команду docker run, передавать тома, прописывать переменные окружения и каждый раз вспоминать, где именно находится конфигурационный файл. Но давайте будем честны.

Никто не любит команды длиной в три экрана терминала.

Особенно когда через несколько месяцев приходится поднимать сервис заново и выясняется, что единственный человек, который помнил правильный набор параметров, сейчас находится в отпуске.

Именно поэтому следующим логичным шагом становится использование Docker Compose.

Он позволяет описать всю конфигурацию сервиса декларативно, хранить её в Git, запускать одной командой и, что особенно приятно, избавляет инженеров от необходимости держать в голове десятки флагов Docker.

Зачем вообще нужен Docker Compose

На первый взгляд может показаться, что Compose избыточен.

У нас всего один контейнер.

Но практика показывает, что инфраструктурные сервисы обладают неприятной привычкой расти.

Сегодня нам нужен только sync-сервис.

Через месяц появляются:

  • Prometheus Exporter;

  • Loki;

  • Fluent Bit;

  • тестовый Keycloak;

  • локальный FreeIPA;

  • контейнеры для интеграционного тестирования.

И внезапно оказывается, что Compose уже не выглядит лишним.

Кроме того, он даёт несколько важных преимуществ:

  • декларативное описание запуска;

  • воспроизводимость;

  • хранение конфигурации в Git;

  • единый способ запуска;

  • удобство локальной разработки;

  • простую миграцию в Kubernetes.

Как будет выглядеть итоговая схема

Для локального стенда архитектура будет выглядеть следующим образом:

               +----------------+
               |   FreeIPA      |
               +--------+-------+
                        ^
                        |
                      LDAP
                        |
                        v
              +---------+--------+
              |   Sync Service   |
              +---------+--------+
                        |
                     REST API
                        |
                        v
                 +------+------+
                 |  Keycloak   |
                 +-------------+

В production FreeIPA и Keycloak обычно уже существуют.

Compose в данном случае используется только для запуска сервиса синхронизации.

Каталог проекта

Перед созданием Compose-файла приведём проект к следующему виду:

ipa-keycloak-sync/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── config.yaml
├── requirements.txt
├── sync.py
├── freeipa.py
├── keycloak_client.py
├── reconciler.py
├── notifier.py
├── audit.py
├── utils.py
├── exceptions.py
└── models.py

Такую структуру удобно хранить в Git и использовать в CI/CD.

Первый вариант docker-compose.yml

Минимальная рабочая версия выглядит следующим образом:

services:

  sync:
    build: .

    container_name: ipa-keycloak-sync

    restart: unless-stopped

    volumes:
      - ./config.yaml:/app/config.yaml:ro

    command:
      - python
      - sync.py

Запуск:

docker compose up

Работает?

Да.

Готово ли это для production?

Пока ещё нет.

Production docker-compose.yml

Улучшим конфигурацию.

services:

  sync:

    build:
      context: .
      dockerfile: Dockerfile

    image: ipa-keycloak-sync:1.0.0

    container_name: ipa-keycloak-sync

    restart: unless-stopped

    volumes:
      - ./config.yaml:/app/config.yaml:ro

    environment:
      TZ: Europe/Moscow

    logging:
      driver: json-file

      options:
        max-size: "10m"
        max-file: "5"

    command:
      - python
      - sync.py

Разберёмся подробнее.

build

build:
  context: .
  dockerfile: Dockerfile

Указывает Compose, как собрать образ.

Если образ уже находится в реестре, этот блок можно заменить:

image: registry.example.local/ipa-keycloak-sync:1.0.0

Именно такой вариант чаще используется в production.

restart policy

restart: unless-stopped

Это одна из самых недооценённых директив Docker Compose.

Если контейнер завершится с ошибкой, Docker автоматически попытается его перезапустить.

Важно понимать:

наш сервис работает как batch-задача.

Он запускается.

Выполняет синхронизацию.

Завершается.

Поэтому подобная политика полезна только в том случае, если сервис работает в режиме постоянного цикла.

Если используется CronJob или cron, она может оказаться бесполезной.

Передача конфигурации

Самый простой способ выглядит так:

volumes:
  - ./config.yaml:/app/config.yaml:ro

Преимущества:

  • удобно;

  • просто;

  • легко редактировать.

Недостатки:

  • файл должен существовать на хосте;

  • секреты попадают на файловую систему.

Для лабораторных стендов этого более чем достаточно.

Настройка логов

По умолчанию Docker хранит логи бесконечно.

Что обычно заканчивается одинаково.

Однажды инженер выполняет:

df -h

и обнаруживает:

Filesystem      Size  Used Avail Use%
/dev/sda1        50G   49G  300M  99%

Потому что контейнер записал несколько гигабайт логов.

Поэтому ограничиваем их:

logging:
  driver: json-file

  options:
    max-size: "10m"
    max-file: "5"

Теперь Docker будет хранить:

  • максимум 5 файлов;

  • по 10 МБ каждый.

Dry-run через Compose

Очень полезный сценарий.

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

Создадим отдельный профиль:

services:

  sync-dry-run:

    extends:
      service: sync

    container_name: ipa-keycloak-sync-dry

    command:
      - python
      - sync.py
      - --dry-run

    profiles:
      - dry-run

Запуск:

docker compose \
  --profile dry-run \
  up

Результат:

DRY RUN: create user petrov
DRY RUN: disable user sidorov
DRY RUN: add ivanov to devops

Никаких изменений внесено не будет.

Использование .env

Очень часто возникает вопрос:

А куда девать пароли?

Плохой вариант:

environment:
  FREEIPA_PASSWORD: SuperPassword

Хороший вариант:

.env
FREEIPA_PASSWORD=SuperPassword
KEYCLOAK_PASSWORD=AnotherPassword

Compose автоматически загрузит его.

Тогда конфигурация выглядит так:

environment:
  FREEIPA_PASSWORD: ${FREEIPA_PASSWORD}
  KEYCLOAK_PASSWORD: ${KEYCLOAK_PASSWORD}

Но действительно ли .env безопасен?

Нет.

Это лишь немного лучше, чем хранить пароль прямо в Compose.

.env:

  • может попасть в Git;

  • лежит на диске открытым текстом;

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

Поэтому для production лучше использовать:

  • Docker Secrets;

  • HashiCorp Vault;

  • Kubernetes Secrets;

  • внешние менеджеры секретов.

Но для небольших стендов .env вполне приемлем.

Docker Secrets

Если используется Docker Swarm, можно сделать так:

echo "SuperPassword" \
  | docker secret create \
    freeipa_password -

И использовать:

secrets:
  - freeipa_password

services:

  sync:
    secrets:
      - freeipa_password

Тогда пароль появится внутри контейнера как файл:

/run/secrets/freeipa_password

Подобный подход значительно безопаснее.

Однократный запуск

Наш сервис чаще всего работает именно как задача синхронизации.

Поэтому наиболее распространённый сценарий выглядит так:

docker compose run --rm sync

Что произойдёт:

Контейнер запустится
↓
Выполнит синхронизацию
↓
Завершит работу
↓
Будет автоматически удалён

Именно так многие команды используют подобные сервисы.

Непрерывный режим

Иногда возникает желание запускать сервис постоянно.

Например:

while True:
    reconcile()

    time.sleep(300)

И запускать его через Compose:

docker compose up -d

Технически это работает.

Но здесь возникает вопрос.

А действительно ли нам нужен постоянно работающий контейнер?

В большинстве случаев ответ будет отрицательным.

Потому что гораздо удобнее запускать синхронизацию периодически.

Например:

  • раз в 5 минут;

  • раз в 15 минут;

  • раз в час.

Именно поэтому далее мы будем использовать более "облачный" подход.

Интеграция с CI/CD

Очень часто Compose используется ещё и в пайплайнах.

Например:

docker compose build
docker compose run --rm sync --dry-run

Это позволяет проверить изменения логики синхронизации ещё до выкатывания в production.

Безопасники обычно очень любят подобные проверки.

Production best practices

За годы эксплуатации Docker Compose сформировались достаточно универсальные рекомендации:

  • не храните секреты внутри Compose;

  • ограничивайте размер логов;

  • используйте .dockerignore;

  • фиксируйте версии образов;

  • храните Compose-файлы в Git;

  • используйте dry-run перед изменением правил;

  • запускайте синхронизацию как batch-задачу;

  • документируйте параметры запуска.

Особенно последний пункт.

Потому что через полгода никто уже не помнит, почему сервис запускается именно с такими флагами, зато все прекрасно помнят, кто однажды случайно выполнил синхронизацию без dry-run и отключил половину подрядчиков в самый неподходящий момент.

Что мы получили в итоге

После внедрения Docker Compose наш сервис получил полноценный механизм эксплуатации вне Kubernetes.

Теперь его можно:

  • запускать одной командой;

  • хранить конфигурацию в Git;

  • использовать dry-run;

  • интегрировать с CI/CD;

  • безопасно ограничивать логи;

  • передавать конфигурацию и секреты;

  • запускать как одноразовую задачу синхронизации.

Но даже Docker Compose остаётся лишь промежуточным этапом эволюции. Потому что если вся остальная инфраструктура уже живёт в Kubernetes, рано или поздно появляется закономерный вопрос:

Зачем вообще держать отдельный Docker-хост, если этот сервис можно запускать прямо внутри кластера?

И именно поэтому следующим шагом мы перенесём наш IAM-контроллер в Kubernetes и реализуем его запуск через CronJob, превратив синхронизацию пользователей в полноценную облачную задачу.

Запуск в Kubernetes через CronJob

Если посмотреть на эволюцию большинства внутренних инфраструктурных сервисов, можно заметить интересную закономерность.

Сначала появляется Bash-скрипт.

Потом его переписывают на Python.

Затем заворачивают в Docker.

После этого запускают через Docker Compose.

И наконец наступает момент, когда кто-нибудь задаёт вполне логичный вопрос:

А зачем нам отдельный сервер с Docker Compose, если всё остальное уже давно работает в Kubernetes?

И действительно.

Если:

  • Grafana работает в Kubernetes;

  • ArgoCD работает в Kubernetes;

  • GitLab интегрирован с Kubernetes;

  • Prometheus собирает метрики из Kubernetes;

  • разработчики деплоят приложения в Kubernetes,

то держать отдельную виртуальную машину исключительно ради периодического запуска sync-сервиса выглядит несколько странно.

Именно поэтому следующим этапом эволюции становится перенос нашего IAM-контроллера внутрь кластера.

Но здесь возникает важный вопрос.

Deployment или CronJob?

Первая мысль большинства инженеров выглядит примерно так:

Сделаем Deployment.

Например:

replicas: 1

Контейнер будет работать постоянно.

Внутри него:

while True:
    reconcile()

    time.sleep(300)

Технически это работает.

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

Во-первых, контейнер большую часть времени ничего не делает.

Во-вторых, нужно самостоятельно реализовывать цикл ожидания.

В-третьих, приходится думать о лидерстве при масштабировании.

В-четвёртых, появляется риск одновременного выполнения нескольких экземпляров.

И самое главное.

Kubernetes уже умеет запускать периодические задачи.

Почему именно CronJob

Наш сервис обладает очень важной особенностью.

Он:

  • запускается;

  • выполняет синхронизацию;

  • завершает работу.

Это классический batch workload.

Именно для подобных сценариев в Kubernetes существует CronJob.

Он позволяет описать:

  • когда запускать задачу;

  • сколько запусков хранить;

  • что делать при ошибках;

  • можно ли запускать несколько экземпляров одновременно;

  • сколько времени ждать завершения.

Фактически это привычный Linux cron, только в Kubernetes.

Как будет выглядеть архитектура

Теперь схема приобретает следующий вид:

                    +----------------+
                    |    FreeIPA     |
                    +--------+-------+
                             ^
                             |
                           LDAPS
                             |
                             v
                 +-----------+-----------+
                 | Kubernetes CronJob    |
                 | ipa-keycloak-sync     |
                 +-----------+-----------+
                             |
                         REST API
                             |
                             v
                      +------+------+
                      |  Keycloak   |
                      +-------------+

При этом контейнер не работает постоянно.

Он появляется только во время синхронизации.

Namespace

Для начала создадим отдельный namespace.

apiVersion: v1
kind: Namespace
metadata:
  name: iam-sync

Применяем:

kubectl apply -f namespace.yaml

Почему отдельный namespace?

Потому что это позволяет:

  • изолировать сервис;

  • проще управлять RBAC;

  • ограничивать ресурсы;

  • собирать метрики;

  • делегировать права.

Secret для конфигурации

Сразу договоримся об одной вещи.

Никогда не делайте так:

env:
  - name: FREEIPA_PASSWORD
    value: SuperPassword

И уж тем более:

password: SuperPassword

в Git.

Даже если репозиторий приватный.

Даже если "это временно".

Потому что нет ничего более постоянного, чем временные решения.

Создадим Secret:

apiVersion: v1
kind: Secret
metadata:
  name: ipa-sync-secrets
  namespace: iam-sync

type: Opaque

stringData:
  FREEIPA_PASSWORD: "SuperPassword"
  KEYCLOAK_PASSWORD: "AnotherPassword"
  TELEGRAM_TOKEN: "123456:ABC"

Применяем:

kubectl apply -f secret.yaml

ConfigMap

Не все настройки являются секретами.

Например:

  • адреса серверов;

  • список управляемых групп;

  • режим работы.

Для них используем ConfigMap.

apiVersion: v1
kind: ConfigMap
metadata:
  name: ipa-sync-config
  namespace: iam-sync

data:
  FREEIPA_HOST: ipa.example.local

  KEYCLOAK_URL: https://keycloak.example.local

  KEYCLOAK_REALM: company

  MANAGED_GROUPS: |
    devops
    developers
    admins

Применяем:

kubectl apply -f configmap.yaml

ServiceAccount

Наш сервис не взаимодействует с Kubernetes API.

Поэтому отдельные права ему не нужны.

Создадим минимальный ServiceAccount.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: ipa-sync
  namespace: iam-sync

Это лучше, чем использовать default.

CronJob

А теперь переходим к самому интересному.

Полный манифест CronJob.

apiVersion: batch/v1
kind: CronJob

metadata:
  name: ipa-keycloak-sync
  namespace: iam-sync

spec:
  schedule: "*/15 * * * *"

  concurrencyPolicy: Forbid

  successfulJobsHistoryLimit: 3

  failedJobsHistoryLimit: 5

  jobTemplate:

    spec:

      backoffLimit: 2

      template:

        spec:

          serviceAccountName: ipa-sync

          restartPolicy: Never

          containers:

            - name: sync

              image: registry.example.local/ipa-keycloak-sync:1.0.0

              imagePullPolicy: IfNotPresent

              envFrom:

                - configMapRef:
                    name: ipa-sync-config

                - secretRef:
                    name: ipa-sync-secrets

              resources:

                requests:
                  cpu: 100m
                  memory: 128Mi

                limits:
                  cpu: 500m
                  memory: 512Mi

Разберём этот манифест подробнее.

schedule

Самая главная строка.

schedule: "*/15 * * * *"

Она означает:

Запускать синхронизацию каждые 15 минут.

Можно выбрать любую периодичность.

Например:

Каждые 5 минут:

schedule: "*/5 * * * *"

Раз в час:

schedule: "0 * * * *"

Каждую ночь:

schedule: "0 2 * * *"

Как часто запускать синхронизацию

Очень распространённый вопрос.

Какой интервал выбрать?

На практике:

Размер организации Интервал
До 50 сотрудников 30-60 минут
50-500 сотрудников 10-15 минут
Более 500 сотрудников 5 минут
Высокие требования ИБ 1-5 минут

На самом деле всё зависит от того, насколько быстро необходимо отзывать доступы.

Если политика компании требует отключать уволенного сотрудника в течение пяти минут, запуск раз в час уже не подходит.

concurrencyPolicy

Одна из важнейших настроек.

concurrencyPolicy: Forbid

Она означает:

Не запускать новую синхронизацию, пока не завершилась предыдущая.

Например:

12:00 Sync стартовал
12:15 Sync ещё работает
12:15 Новый запуск пропущен
12:30 Следующий запуск выполнится

Альтернативы:

Разрешить параллельные запуски:

Allow

Убивать предыдущий запуск:

Replace

Для IAM-сервисов почти всегда используют именно:

Forbid

Потому что два одновременно работающих reconciliation loop способны устроить весьма необычные эффекты.

successfulJobsHistoryLimit

successfulJobsHistoryLimit: 3

Хранить последние три успешных запуска.

Если не ограничивать историю, через некоторое время кластер окажется заполнен старыми Job.

failedJobsHistoryLimit

failedJobsHistoryLimit: 5

Хранить последние пять неудачных запусков.

Это очень полезно при расследовании инцидентов.

backoffLimit

backoffLimit: 2

Количество повторных запусков Job при ошибке.

Например:

Попытка №1 → ошибка
Попытка №2 → ошибка
Попытка №3 → ошибка
Job считается неуспешной

Важно понимать.

У нас уже есть retry внутри Python-кода.

Поэтому слишком большое значение здесь не требуется.

Ресурсы

Не забываем ограничивать контейнер.

resources:

  requests:
    cpu: 100m
    memory: 128Mi

  limits:
    cpu: 500m
    memory: 512Mi

Для нескольких сотен пользователей этого более чем достаточно.

Если пользователей тысячи, память может потребоваться увеличить.

Dry-run в Kubernetes

Иногда хочется проверить изменения без применения.

Самый простой вариант:

kubectl create job \
  --from=cronjob/ipa-keycloak-sync \
  ipa-sync-dry-run

Затем изменить команду:

args:
  - "--dry-run"

И посмотреть результат.

Подобная возможность очень помогает перед изменением логики назначения ролей.

Ручной запуск

Иногда синхронизацию нужно выполнить немедленно.

Например, после массового увольнения подрядчиков.

Выполняем:

kubectl create job \
  --from=cronjob/ipa-keycloak-sync \
  manual-sync

Kubernetes создаст одноразовый Job.

Просмотр логов

Список Job:

kubectl get jobs \
  -n iam-sync

Pods:

kubectl get pods \
  -n iam-sync

Логи:

kubectl logs \
  job/manual-sync \
  -n iam-sync

Например:

2026-06-15 12:00:01 INFO Sync started
2026-06-15 12:00:02 INFO Connected to FreeIPA
2026-06-15 12:00:03 INFO Connected to Keycloak
2026-06-15 12:00:04 INFO Disabled user contractor01
2026-06-15 12:00:05 INFO Sync completed

Что происходит при сбое

Представим ситуацию.

FreeIPA недоступен.

Последовательность будет выглядеть так:

CronJob
↓
Создаёт Job
↓
Python выполняет retry
↓
Retry не помогает
↓
Job завершается ошибкой
↓
Kubernetes выполняет повторный запуск
↓
После превышения backoffLimit Job помечается как Failed

При этом:

  • событие попадёт в аудит;

  • уведомление уйдёт в Telegram;

  • логи сохранятся в Kubernetes.

Именно так и должен вести себя production-сервис.

Почему CronJob лучше, чем Deployment

Если обобщить всё вышесказанное, преимущества CronJob становятся очевидными.

Он:

  • не расходует ресурсы между запусками;

  • не требует бесконечных циклов в коде;

  • не требует лидерства;

  • не допускает одновременных запусков;

  • автоматически ведёт историю выполнения;

  • естественно вписывается в Kubernetes.

Фактически Kubernetes берёт на себя всю операционную часть запуска.

Production best practices

За годы эксплуатации CronJob сформировался достаточно предсказуемый набор рекомендаций:

  • используйте concurrencyPolicy: Forbid;

  • ограничивайте историю Job;

  • задавайте requests и limits;

  • не храните секреты в Git;

  • используйте отдельный ServiceAccount;

  • регулярно проверяйте успешность выполнения;

  • тестируйте новые правила через dry-run;

  • документируйте периодичность синхронизации.

Особенно последний пункт.

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

Что мы получили в итоге

После переноса в Kubernetes наш сервис окончательно превратился в полноценный облачный компонент платформы.

Теперь он:

  • запускается автоматически по расписанию;

  • масштабируется вместе с кластером;

  • использует встроенные механизмы Kubernetes;

  • безопасно хранит конфигурацию и секреты;

  • ограничивает потребление ресурсов;

  • ведёт историю выполнения;

  • поддерживает ручной запуск и dry-run.

Именно на этом этапе многие команды понимают, что синхронизация пользователей перестала быть "небольшим Python-скриптом". Она превратилась в полноценный инфраструктурный сервис, от которого зависит безопасность всей платформы.

И здесь мы подходим к ещё одной крайне важной теме. До сих пор мы говорили о секретах вскользь, но сервис, имеющий доступ к FreeIPA, Keycloak и Telegram, неизбежно работает с чувствительными данными. Поэтому следующим шагом мы подробно разберём, как организовать секреты и безопасное хранение конфигурации, не превращая config.yaml в коллекцию паролей, случайно закоммиченных в Git.

Секреты и безопасное хранение конфигурации

Есть одна удивительная закономерность в мире инфраструктуры.

Практически любой проект начинается со слов:

"Сейчас быстренько проверим, потом нормально сделаем."

И именно после этой фразы в репозитории появляется файл config.yaml, содержащий примерно следующее:

freeipa:
  password: SuperPassword

keycloak:
  password: Admin123

telegram:
  token: 123456789:ABCDEF

Дальше происходит классический сценарий.

Сначала этот файл случайно попадает в Git.

Потом его копируют на тестовый сервер.

Потом кто-то отправляет архив проекта в корпоративный чат.

Потом выясняется, что пароль от Keycloak используется ещё и в других системах.

А потом безопасники задают очень неприятный вопрос:

А сколько вообще человек имеют доступ к этим секретам?

И вот в этот момент становится понятно, что управление секретами - это не дополнительная опция. Это одна из важнейших частей любой production-инфраструктуры.

Особенно если речь идёт о сервисе, который обладает доступом одновременно к FreeIPA, Keycloak и системам уведомлений.

Какие секреты есть в нашем сервисе

Для начала определим, что вообще считается секретом.

В нашем проекте присутствуют следующие чувствительные данные:

  • пароль сервисной учётной записи FreeIPA;

  • пароль сервисной учётной записи Keycloak;

  • Telegram Bot Token;

  • токены внешних систем;

  • сертификаты;

  • закрытые ключи;

  • API-ключи;

  • клиентские секреты OIDC.

Например:

freeipa:
  bind_dn: uid=sync,...
  password: SuperPassword

keycloak:
  username: sync
  password: AnotherPassword

telegram:
  token: 123456:ABCDEF

Все эти значения позволяют получить доступ к критически важным системам.

Именно поэтому к ним необходимо относиться как к инфраструктурным активам.

Самый плохой вариант

К сожалению, именно он встречается чаще всего.

config.yaml
freeipa:
  password: SuperPassword

keycloak:
  password: AnotherPassword

И затем:

git add .
git commit -m "initial version"
git push

Поздравляем.

Теперь секреты:

  • попали в историю Git;

  • были скачаны всеми разработчиками;

  • попали в резервные копии;

  • скорее всего, никогда не будут удалены полностью.

Даже если позже их убрать из репозитория.

Git очень хорошо помнит прошлое.

Немного менее плохой вариант - .env

Следующий этап эволюции обычно выглядит так:

.env
FREEIPA_PASSWORD=SuperPassword
KEYCLOAK_PASSWORD=AnotherPassword
TELEGRAM_TOKEN=123456:ABCDEF

Загрузка:

import os

freeipa_password = os.getenv(
    "FREEIPA_PASSWORD"
)

Преимущества:

  • секреты отсутствуют в коде;

  • конфигурация разделена;

  • удобно использовать в Docker Compose.

Недостатки:

  • файл лежит открытым текстом;

  • может попасть в Git;

  • попадает в резервные копии;

  • доступен администраторам сервера.

Не забываем про .gitignore

Если всё же используется .env, обязательно добавляем:

.env
.env.*

в .gitignore.

Например:

.git
venv
__pycache__
.env
.env.*

Это не делает хранение безопасным.

Но хотя бы предотвращает случайную публикацию секретов.

Переменные окружения в Docker

Очень популярный вариант.

Compose:

services:

  sync:

    environment:
      FREEIPA_PASSWORD: ${FREEIPA_PASSWORD}
      KEYCLOAK_PASSWORD: ${KEYCLOAK_PASSWORD}

Контейнер:

password = os.getenv(
    "FREEIPA_PASSWORD"
)

Плюсы:

  • удобно;

  • интегрируется с Compose;

  • легко использовать в CI/CD.

Минусы:

  • переменные окружения можно увидеть через:

docker inspect
  • попадают в диагностические дампы;

  • могут отображаться в системах мониторинга.

Для небольших стендов этого достаточно.

Но не для серьёзного production.

Docker Secrets

Если используется Docker Swarm, появляется более безопасный механизм.

Создание секрета:

echo "SuperPassword" \
  | docker secret create \
    freeipa_password -

Использование:

services:

  sync:

    secrets:
      - freeipa_password

secrets:

  freeipa_password:
    external: true

Внутри контейнера:

/run/secrets/freeipa_password

Чтение:

with open(
    "/run/secrets/freeipa_password"
) as f:
    password = f.read().strip()

Преимущества:

  • секреты не отображаются в Compose;

  • не передаются через переменные окружения;

  • доступны только контейнеру.

Kubernetes Secrets

Поскольку наш сервис работает в Kubernetes, именно этот вариант используется чаще всего.

Создаём Secret:

apiVersion: v1
kind: Secret

metadata:
  name: ipa-sync-secrets

type: Opaque

stringData:
  FREEIPA_PASSWORD: SuperPassword
  KEYCLOAK_PASSWORD: AnotherPassword
  TELEGRAM_TOKEN: 123456:ABCDEF

Применяем:

kubectl apply -f secret.yaml

Не храните Secret в Git

Очень часто можно встретить следующее:

git add secret.yaml
git push

И это полностью уничтожает весь смысл Kubernetes Secrets.

Потому что:

Kubernetes Secret
=
base64
=
не шифрование

Например:

data:
  password: U3VwZXJQYXNzd29yZA==

Легко декодируется:

echo U3VwZXJQYXNzd29yZA== \
  | base64 -d

Результат:

SuperPassword

Использование Secret как переменных окружения

Самый распространённый вариант.

env:

- name: FREEIPA_PASSWORD

  valueFrom:
    secretKeyRef:
      name: ipa-sync-secrets
      key: FREEIPA_PASSWORD

Python:

os.getenv(
    "FREEIPA_PASSWORD"
)

Просто и удобно.

Использование Secret как файлов

Более безопасный вариант.

volumeMounts:

- name: secrets
  mountPath: /etc/secrets
  readOnly: true

Volumes:

volumes:

- name: secrets

  secret:
    secretName: ipa-sync-secrets

Внутри контейнера:

/etc/secrets/FREEIPA_PASSWORD
/etc/secrets/KEYCLOAK_PASSWORD

Чтение:

with open(
    "/etc/secrets/FREEIPA_PASSWORD"
) as f:
    password = f.read().strip()

Преимущества:

  • секреты не попадают в окружение процесса;

  • их сложнее случайно вывести в лог;

  • они не отображаются в kubectl describe pod.

External Secrets Operator

Когда инфраструктура начинает расти, Kubernetes Secrets быстро становятся неудобными.

Появляются вопросы:

  • как ротировать пароли;

  • как синхронизировать секреты между кластерами;

  • кто должен ими управлять.

Именно здесь появляется External Secrets Operator.

Архитектура выглядит так:

HashiCorp Vault
        ↓
External Secrets Operator
        ↓
Kubernetes Secret
        ↓
Pod

Или:

AWS Secrets Manager
        ↓
ESO
        ↓
Kubernetes

HashiCorp Vault

Для серьёзного production именно Vault чаще всего становится источником истины.

Например:

secret/ipa-sync/freeipa
secret/ipa-sync/keycloak
secret/ipa-sync/telegram

Получение секрета:

vault kv get \
  secret/ipa-sync/freeipa

Преимущества:

  • аудит обращений;

  • ротация;

  • разграничение доступа;

  • динамические секреты;

  • централизованное управление.

Недостатки:

  • дополнительный компонент;

  • сложность эксплуатации.

Но если у вас уже есть Vault, использовать его стоит обязательно.

Ротация секретов

Очень многие команды забывают про этот процесс.

И пароль сервисной учётной записи живёт годами.

Правильный подход выглядит так:

Создание нового секрета
        ↓
Обновление Secret/Vault
        ↓
Перезапуск CronJob
        ↓
Проверка работы
        ↓
Удаление старого секрета

Периодичность зависит от политики компании.

Обычно:

  • 90 дней;

  • 180 дней;

  • 365 дней.

Минимизация привилегий

Сервисным учётным записям нужны только необходимые права.

Например:

FreeIPA:

LDAP Read Only

Keycloak:

manage-users
view-users
manage-groups

Но не:

realm-admin

Потому что если сервис будет скомпрометирован, последствия окажутся существенно менее разрушительными.

Что делать при утечке

Предположим худшее произошло.

Секрет оказался в Git.

Порядок действий:

1. Считать секрет скомпрометированным
2. Выпустить новый
3. Обновить все системы
4. Перезапустить сервис
5. Отозвать старый
6. Проверить аудит
7. Найти источник утечки

И самое главное:

Никогда не ограничивайтесь удалением файла из репозитория.

Если секрет попал в Git, он уже считается утраченным.

Production best practices

За годы эксплуатации сформировался достаточно очевидный набор правил:

  • не храните секреты в коде;

  • не коммитьте Secret в Git;

  • используйте .gitignore;

  • минимизируйте права сервисных учётных записей;

  • используйте файловые Secret вместо переменных окружения;

  • внедряйте ротацию;

  • включайте аудит обращений;

  • используйте Vault или External Secrets Operator при росте инфраструктуры;

  • документируйте процесс замены секретов.

Особенно последний пункт.

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

Что мы получили в итоге

К этому моменту наш сервис уже умеет:

  • безопасно хранить конфигурацию;

  • использовать Kubernetes Secrets;

  • работать с Docker Secrets;

  • интегрироваться с Vault;

  • поддерживать ротацию;

  • минимизировать привилегии;

  • переживать компрометацию секретов.

И именно здесь становится понятно, что настоящий IAM - это не только синхронизация пользователей. Это ещё и грамотное обращение с самыми чувствительными данными инфраструктуры.

Теперь, когда фундамент безопасности построен, можно переходить к следующей задаче - синхронизации групп и role mapping, где мы наконец научим систему автоматически превращать корпоративные группы FreeIPA в реальные права доступа внутри Keycloak и подключённых приложений.

К этому моменту у нас появился полноценный production-ready сервис, способный работать как на обычных серверах, так и внутри Kubernetes. Он умеет безопасно хранить секреты, запускается в контейнерах и автоматически синхронизирует данные между FreeIPA и Keycloak.

Но пока что он остаётся лишь механизмом доставки пользователей. Настоящая ценность системы управления идентификацией раскрывается тогда, когда она начинает понимать, какие именно права должен получать сотрудник и как управлять ими на протяжении всего жизненного цикла.

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