После прочтения первой части могло сложиться впечатление, что встроенных возможностей 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.
Но пока что он остаётся лишь механизмом доставки пользователей. Настоящая ценность системы управления идентификацией раскрывается тогда, когда она начинает понимать, какие именно права должен получать сотрудник и как управлять ими на протяжении всего жизненного цикла.
Именно этим мы и займёмся в следующей части, где научим систему автоматически назначать роли, отзывать доступы и управлять цифровой личностью сотрудника от первого рабочего дня до момента увольнения.