Создать пользователя значительно проще, чем определить, что именно ему разрешено делать.

Большинство проблем современных IAM-систем возникает не на этапе аутентификации, а на этапе авторизации. Кто должен иметь доступ к production? Какие права положены подрядчикам? Что произойдёт после перевода сотрудника в другую команду? Как гарантировать, что увольнение автоматически приведёт к отзыву всех доступов?

Ответы на эти вопросы и отличают простую синхронизацию учётных записей от полноценной системы управления идентификацией.

В этой части мы научим нашу платформу принимать подобные решения автоматически.

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

Если спросить любого инфраструктурного инженера, что является самой сложной частью управления идентификацией, многие ответят:

Настроить LDAP.

Кто-то скажет:

Разобраться с сертификатами.

Кто-то вспомнит про Kerberos и его любовь к DNS.

Но практика показывает, что настоящая боль начинается значительно позже.

Когда приходит время отвечать на вопрос:

Хорошо. Пользователь вошёл в систему. А что ему теперь разрешено делать?

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

Потому что наличие учётной записи ещё не означает наличие прав.

Аутентификация против авторизации

Очень важно понимать разницу между этими понятиями.

Аутентификация отвечает на вопрос:

Кто ты?

Авторизация отвечает на вопрос:

Что тебе разрешено?

Например:

ivanov успешно вошёл в Keycloak

Это аутентификация.

Но дальше возникает вопрос:

Можно ли ему деплоить в production?

И это уже авторизация.

Именно поэтому после синхронизации пользователей возникает необходимость в role mapping.

Что такое Role Mapping

Role Mapping - это процесс преобразования корпоративных групп в реальные права доступа.

Например:

FreeIPA
↓

devops
developers
contractors

↓

Keycloak

argocd-admin
grafana-admin
harbor-admin

Именно здесь бизнес-требования превращаются в технические настройки.

Самый простой вариант

Предположим, что в FreeIPA существуют следующие группы:

devops
developers
vpn-users

В Keycloak существуют роли:

argocd-admin
grafana-admin
grafana-viewer
vpn-access

Тогда соответствие может выглядеть так:

Группа FreeIPA Роли Keycloak
devops argocd-admin, grafana-admin
developers grafana-viewer
vpn-users vpn-access

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

До тех пор, пока компания не начинает расти.

Почему прямое соответствие быстро перестаёт работать

Представим организацию из тридцати человек.

Группы:

devops
developers
admins

Role Mapping:

devops → admin
developers → viewer
admins → super-admin

Теперь представим организацию из пятисот человек.

Появляются:

  • подрядчики;

  • стажёры;

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

  • команды сопровождения;

  • инженеры on-call;

  • платформенная команда;

  • региональные офисы;

  • безопасники.

Количество комбинаций начинает расти экспоненциально.

Например:

devops-prod
devops-stage
devops-emea
devops-asia
contractors-devops
platform-admins

Если пытаться поддерживать это вручную, очень быстро наступает хаос.

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

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

Например:

role_mappings:

  devops:
    - argocd-admin
    - grafana-admin
    - harbor-admin

  developers:
    - grafana-viewer

  vpn-users:
    - vpn-access

  contractors:
    - grafana-viewer

Преимущества очевидны:

  • правила хранятся в Git;

  • проходят review;

  • поддерживают историю изменений;

  • могут проверяться автоматически.

Как работает преобразование

Допустим, пользователь приходит из FreeIPA:

username: ivanov

groups:
  - devops
  - vpn-users

Sync Service загружает правила:

devops:
  - argocd-admin
  - grafana-admin

vpn-users:
  - vpn-access

И вычисляет итоговый набор ролей:

ivanov

↓

argocd-admin
grafana-admin
vpn-access

Именно этот набор затем назначается пользователю.

Структура role_mappings.yaml

Вынесем правила в отдельный файл.

mappings:

  devops:
    - argocd-admin
    - grafana-admin
    - harbor-admin

  developers:
    - grafana-viewer

  platform:
    - argocd-admin
    - prometheus-admin

  contractors:
    - grafana-viewer

  vpn-users:
    - vpn-access

Почему отдельно?

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

Загружаем правила

Добавим функцию:

import yaml

def load_role_mapping(
    path="role_mappings.yaml",
):
    with open(path) as f:
        return yaml.safe_load(f)

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

mappings = load_role_mapping()

print(
    mappings["mappings"]["devops"]
)

Результат:

[
  "argocd-admin",
  "grafana-admin",
  "harbor-admin"
]

Вычисление ролей

Создадим функцию:

def calculate_roles(
    user,
    mappings,
):
    roles = set()

    for group in user.groups:
        roles.update(
            mappings.get(
                "mappings",
                {},
            ).get(
                group,
                [],
            )
        )

    return list(roles)

Почему используется set?

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

Например:

devops
platform

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

Пример работы

Пользователь:

username: ivanov

groups:
  - devops
  - platform

Role Mapping:

devops:
  - grafana-admin
  - harbor-admin

platform:
  - argocd-admin
  - grafana-admin

Результат:

argocd-admin
grafana-admin
harbor-admin

Без дублирования.

Realm Roles и Client Roles

Здесь появляется очень важный момент.

В Keycloak существует два типа ролей.

Realm Roles

Глобальные роли Realm.

Например:

admin
viewer
developer

Они доступны всем клиентам.

Client Roles

Роли конкретного приложения.

Например:

grafana:
  admin
  viewer

argocd:
  admin
  readonly

На практике именно Client Roles используются значительно чаще.

Потому что позволяют избежать конфликтов.

Пример структуры

Например:

mappings:

  devops:

    grafana:
      - admin

    argocd:
      - admin

    harbor:
      - admin

  developers:

    grafana:
      - viewer

    argocd:
      - readonly

Подобная структура гораздо лучше масштабируется.

Вычисление Client Roles

Функция становится немного сложнее.

def calculate_client_roles(
    user,
    mappings,
):
    result = {}

    for group in user.groups:

        client_roles = (
            mappings.get(
                "mappings",
                {},
            ).get(
                group,
                {},
            )
        )

        for client, roles in (
            client_roles.items()
        ):

            result.setdefault(
                client,
                set(),
            )

            result[client].update(
                roles
            )

    return {
        client: list(roles)
        for client, roles
        in result.items()
    }

Назначение ролей в Keycloak

Получаем пользователя:

user_id = kc_user["id"]

Получаем клиента:

client_id = keycloak.get_client_id(
    "grafana"
)

Получаем роль:

role = keycloak.get_client_role(
    client_id,
    "admin",
)

Назначаем:

keycloak.admin.assign_client_role(
    user_id,
    client_id,
    [role],
)

Удаление лишних ролей

Очень многие забывают про этот момент.

Например:

Вчера:
ivanov ∈ devops

Сегодня:
ivanov покинул devops

Если только назначать роли, получится следующее:

ivanov

argocd-admin
grafana-admin

останутся навсегда.

Именно поэтому reconciliation должен работать в обе стороны.

Desired State против Current State

Получаем роли пользователя:

current_roles = {
    "argocd-admin",
    "grafana-admin",
}

Вычисляем желаемые:

desired_roles = {
    "grafana-admin",
}

Находим разницу:

roles_to_add = (
    desired_roles
    - current_roles
)

roles_to_remove = (
    current_roles
    - desired_roles
)

И применяем изменения.

Dry-run для Role Mapping

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

Например:

DRY RUN

User: ivanov

Add:
- harbor-admin

Remove:
- argocd-admin

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

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

Исключения из правил

Рано или поздно появляется такой запрос:

Всем DevOps выдать доступ в production. Кроме Пети.

Именно в этот момент начинается разрушение красивой архитектуры.

Поэтому лучше сразу предусмотреть исключения.

Например:

overrides:

  ivanov:
    add:
      - emergency-admin

    remove:
      - harbor-admin

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

Потому что количество исключений имеет неприятную тенденцию расти.

Когда пора пересматривать модель

Есть простой индикатор.

Если ваш role_mappings.yaml выглядит так:

mappings:
  ...

и занимает двадцать строк - всё хорошо.

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

Например:

  • Open Policy Agent;

  • Cedar;

  • собственном Policy Engine.

Production best practices

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

  • храните правила в Git;

  • используйте Pull Request;

  • применяйте dry-run;

  • удаляйте лишние роли;

  • используйте reconciliation loop;

  • предпочитайте Client Roles;

  • минимизируйте количество исключений;

  • документируйте бизнес-логику;

  • регулярно проводите аудит назначений.

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

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

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

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

  • превращать LDAP-группы в реальные права доступа;

  • вычислять желаемое состояние ролей;

  • назначать недостающие роли;

  • удалять лишние;

  • работать с Realm Roles и Client Roles;

  • поддерживать dry-run;

  • хранить правила в Git;

  • применять принципы GitOps к управлению доступами.

И именно здесь синхронизация пользователей окончательно превращается в полноценное управление идентификацией. Потому что настоящий IAM отвечает не только на вопрос:

Кто этот человек?

Но и на гораздо более важный:

Что именно он имеет право делать внутри нашей инфраструктуры?

Автоматическая блокировка уволенных сотрудников

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

Не автоматическое создание пользователей.

Не назначение ролей.

Не Telegram-уведомления.

И даже не красивые GitOps-манифесты.

А именно автоматическую блокировку уволенных сотрудников.

Потому что пока инженеры спорят о сервисных сетях, Sidecar-контейнерах и новых возможностях Kubernetes, одна из самых распространённых причин инцидентов информационной безопасности остаётся удивительно простой:

У человека уже нет трудовых отношений с компанией, а доступы всё ещё работают.

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

Почему это настолько важно

Давайте представим абсолютно обычную ситуацию.

Пятница.

18:30.

Сотрудник увольняется.

HR оформляет документы.

Руководитель пожимает руку.

Но дальше начинается самое интересное.

Доступы сотрудника находятся в:

  • FreeIPA;

  • Keycloak;

  • Grafana;

  • ArgoCD;

  • GitLab;

  • Harbor;

  • VPN;

  • Jenkins;

  • OpenSearch;

  • Jira;

  • Confluence;

  • внутренних сервисах.

Если отзыв прав выполняется вручную, процесс обычно выглядит так:

HR
 ↓
Письмо администратору
 ↓
Администратор увидел письмо
 ↓
Создал задачу
 ↓
Вспомнил про часть систем
 ↓
Забыл про остальные
 ↓
Выходные

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

Причём чаще всего проблема возникает не из-за злого умысла.

А потому что люди ошибаются.

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

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

Без звонков.

Без писем.

Без человеческого фактора.

Например:

HR
 ↓
Увольнение сотрудника
 ↓
FreeIPA
 ↓
Учётная запись отключена
 ↓
CronJob запускает sync
 ↓
Keycloak блокирует пользователя
 ↓
OIDC-токены перестают выдаваться
 ↓
Доступ исчезает во всех системах

Именно к такой модели мы и стремимся.

Что считать увольнением

На практике существует несколько вариантов.

Вариант №1. Пользователь удалён из FreeIPA

Например:

ipa user-del ivanov

После этого:

ivanov отсутствует в LDAP

Плюсы:

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

  • легко обнаруживается.

Минусы:

  • теряется часть информации;

  • усложняется аудит;

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

Вариант №2. Пользователь заблокирован

Например:

ipa user-disable ivanov

В FreeIPA:

nsAccountLock=TRUE

Плюсы:

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

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

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

Минусы:

  • требуется дополнительная логика обработки.

Вариант №3. Использование отдельного атрибута

Например:

employeeStatus=terminated

или:

employmentStatus=inactive

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

Особенно если FreeIPA интегрирован с HR-системами.

Какой вариант лучше

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

Например:

ipa user-disable ivanov

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

  • историю действий;

  • привязку к аудиту;

  • события входа;

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

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

Особенно в организациях с серьёзными требованиями ИБ.

Обнаружение уволенных сотрудников

Наш reconciliation loop уже умеет вычислять различия.

До сих пор мы использовали следующий подход:

FreeIPA Users
↓
Keycloak Users
↓
Найти отсутствующих

Теперь сделаем его умнее.

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

Например:

ldap_usernames = {
    user.username
    for user in freeipa_users
}

for username, kc_user in existing_users.items():

    if username not in ldap_usernames:
        disable_user(kc_user)

Но этого недостаточно.

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

Расширяем модель User

Добавим новое поле.

@dataclass
class User:
    username: str
    email: str
    first_name: str
    last_name: str
    groups: list[str]

    enabled: bool = True

Поле enabled становится крайне важным.

Получаем статус из FreeIPA

В freeipa.py добавим атрибут:

attributes=[
    "uid",
    "mail",
    "givenName",
    "sn",
    "memberOf",
    "nsAccountLock",
]

Формируем пользователя:

enabled = True

if "nsAccountLock" in entry:
    enabled = (
        str(entry.nsAccountLock)
        != "TRUE"
    )

Создаём модель:

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

Теперь сервис знает не только о существовании пользователя.

Но и о его состоянии.

Блокировка в Keycloak

Сам механизм блокировки удивительно прост.

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

С точки зрения Keycloak это означает:

Пользователь существует, но не может входить в систему.

Разблокировка

Очень важный момент.

Что делать, если сотрудника восстановили?

Например:

ipa user-enable ivanov

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

Поэтому реализуем обратную операцию.

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

Теперь процесс становится двусторонним.

Обновляем reconciler

Добавим обработку статуса.

for user in freeipa_users:

    kc_user = existing_users.get(
        user.username
    )

    if not kc_user:
        continue

    kc_enabled = kc_user.get(
        "enabled",
        True,
    )

    if user.enabled and not kc_enabled:
        enable_user(
            kc_user["id"]
        )

    elif (
        not user.enabled
        and kc_enabled
    ):
        disable_user(
            kc_user["id"]
        )

Dry-run режим

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

Например:

DRY RUN

Disable:
- ivanov
- petrov

Enable:
- sidorov

Подобный режим способен спасти огромное количество нервных клеток.

Особенно после изменений логики синхронизации.

Что делать с активными сессиями

И вот здесь начинается самое интересное.

Предположим:

12:00
ivanov уволен

CronJob запускается:

12:05
ivanov отключён

Но в Keycloak у него уже существует активная сессия.

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

Ответ зависит от приложения.

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

Иногда продолжит работать до истечения токена.

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

Завершаем сессии Keycloak

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

sessions = (
    keycloak.admin.get_sessions(
        user_id
    )
)

Удаляем их:

for session in sessions:

    keycloak.admin.user_logout(
        user_id
    )

Теперь результат выглядит так:

Увольнение
↓
Keycloak блокирует пользователя
↓
Все активные сессии завершаются
↓
Повторный вход невозможен

Именно такого поведения обычно ожидают безопасники.

Уведомления

Блокировка пользователя должна быть заметной.

Например:

notifier.send(
    (
        "⛔ User disabled\n"
        f"User: {username}"
    )
)

Telegram:

⛔ User disabled

User: ivanov

Массовые увольнения

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

Например:

Disable:
- contractor01
- contractor02
- contractor03
...
- contractor52

Что делать?

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

Защита от катастроф

Добавим предохранитель.

MAX_DISABLES = 10

if len(users_to_disable) > MAX_DISABLES:

    raise RuntimeError(
        (
            "Refusing to disable "
            f"{len(users_to_disable)} users"
        )
    )

Например:

Expected: 2 disables
Actual: 57 disables

Sync aborted.

Подобная проверка способна предотвратить очень неприятные последствия ошибки LDAP-фильтра.

Аудит

Каждое действие должно фиксироваться.

Например:

audit(
    "USER_DISABLED",
    username,
)

или:

audit(
    "USER_ENABLED",
    username,
)

Лог:

[AUDIT] USER_DISABLED: ivanov

[AUDIT] USER_ENABLED: sidorov

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

Почему пользователь потерял доступ?

Интеграция с HR

В зрелых организациях FreeIPA редко является первоисточником.

Чаще цепочка выглядит так:

HR-система
↓
FreeIPA
↓
Sync Service
↓
Keycloak
↓
Applications

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

А вся инфраструктура лишь автоматически исполняет принятые решения.

Production best practices

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

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

  • завершайте активные сессии;

  • поддерживайте автоматическую разблокировку;

  • используйте dry-run;

  • ограничивайте массовые отключения;

  • уведомляйте ответственных;

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

  • регулярно тестируйте сценарий увольнения;

  • интегрируйтесь с HR-системами.

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

Потому что удивительно большое количество компаний никогда не проверяет процесс увольнения сотрудников до первого настоящего инцидента.

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

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

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

Теперь он умеет:

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

  • учитывать статус учётной записи в FreeIPA;

  • блокировать пользователей в Keycloak;

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

  • завершать активные сессии;

  • уведомлять ответственных;

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

  • защищаться от массовых ошибочных отключений.

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

Telegram-уведомления и интеграция с внешними системами

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

На первой стадии она просто создаёт пользователей.

На второй начинает назначать роли.

На третьей автоматически блокирует уволенных сотрудников.

А затем неизбежно появляется вопрос:

А как мы вообще узнаем, что всё это происходит?

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

Потому что автоматизация, о которой никто не знает, очень быстро превращается в чёрный ящик.

А чёрные ящики инфраструктурные инженеры не любят.

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

Почему половина подрядчиков потеряла доступ?

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

Почему уведомления важны

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

Например:

INFO User created: ivanov
INFO User disabled: petrov
INFO Sync completed

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

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

Логи аккуратно собираются в OpenSearch
↓
Хранятся несколько месяцев
↓
Никто их не открывает
↓
До первого инцидента

Поэтому события должны быть заметными.

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

Какие события стоит отправлять

Очень распространённая ошибка выглядит так:

Давайте отправлять вообще всё.

Через несколько дней Telegram-чат превращается в поток из сотен сообщений.

И люди начинают их игнорировать.

Поэтому уведомлять стоит только о действительно важных событиях.

Например:

Успешная синхронизация

Sync completed

Created: 3
Updated: 7
Disabled: 1
Duration: 14 seconds

Ошибка синхронизации

Sync failed

Reason:
Cannot connect to FreeIPA

Массовое отключение пользователей

Warning

25 users are about to be disabled

Назначение критичных ролей

Например:

User ivanov
received role
argocd-admin

Подозрительные события

Например:

User disappeared from FreeIPA
but still has active sessions

Или:

Role mapping changed
for production administrators

Почему именно Telegram

Если говорить откровенно, Telegram давно стал одним из самых популярных каналов уведомлений среди инфраструктурных команд.

Причин несколько.

Во-первых, он есть практически у всех.

Во-вторых, интеграция занимает несколько минут.

В-третьих, мобильные уведомления работают очень хорошо.

В-четвёртых, сообщения удобно читать.

Конечно, существуют альтернативы:

  • Slack;

  • Microsoft Teams;

  • Mattermost;

  • электронная почта;

  • PagerDuty;

  • Opsgenie.

Но Telegram остаётся своеобразным "дефолтным мессенджером DevOps".

Особенно в русскоязычном сообществе.

Создаём Telegram-бота

Открываем:

@BotFather

Выполняем:

/newbot

Получаем токен:

123456789:ABCDEF...

Сохраняем его в Secret.

Например:

stringData:
  TELEGRAM_TOKEN: 123456789:ABCDEF

Получаем chat_id

Самая загадочная часть интеграции.

После отправки сообщения боту выполняем:

curl \
  https://api.telegram.org/botTOKEN/getUpdates

Ответ:

{
  "result": [
    {
      "message": {
        "chat": {
          "id": -1001234567890
        }
      }
    }
  ]
}

Именно это значение нам и понадобится.

Расширяем notifier.py

До этого момента наш notifier выглядел достаточно просто.

Теперь превратим его в полноценный сервис уведомлений.

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(
                (
                    "https://api.telegram.org"
                    f"/bot{self.token}"
                    "/sendMessage"
                ),
                json={
                    "chat_id": self.chat_id,
                    "text": text,
                },
                timeout=10,
            )

        except Exception as exc:

            logger.exception(
                "Telegram error: %s",
                exc,
            )

Форматирование сообщений

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

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

Например:

message = (
    "✅ Sync completed\n\n"
    "Created: 3\n"
    "Updated: 7\n"
    "Disabled: 1\n"
    "Duration: 14 sec"
)

notifier.send(message)

Результат:

✅ Sync completed

Created: 3
Updated: 7
Disabled: 1
Duration: 14 sec

Уведомления об ошибках

Самые важные уведомления.

Например:

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

Telegram:

❌ Sync failed

Error:
Cannot connect to FreeIPA

Уведомления о блокировке пользователей

Особенно полезны для службы безопасности.

notifier.send(
    (
        "⛔ User disabled\n\n"
        f"Username: {username}"
    )
)

Например:

⛔ User disabled

Username: ivanov

Массовые отключения

Предположим, reconciliation решил отключить двадцать пользователей.

Перед выполнением отправим предупреждение.

if len(users_to_disable) > 10:

    notifier.send(
        (
            "⚠️ Mass disable detected\n\n"
            f"Users: "
            f"{len(users_to_disable)}"
        )
    )

Telegram:

⚠️ Mass disable detected

Users: 25

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

Markdown в Telegram

Telegram умеет красиво форматировать сообщения.

Например:

requests.post(
    url,
    json={
        "chat_id": chat_id,
        "text": (
            "*Sync completed*\n"
            "`Created: 3`"
        ),
        "parse_mode": "Markdown",
    },
)

Результат:

Sync completed
Created: 3

становится значительно более читаемым.

Добавляем уровень важности

Очень удобный подход.

INFO
WARNING
ERROR
CRITICAL

Например:

def notify_error(text):
    notifier.send(
        f"❌ ERROR\n\n{text}"
    )

def notify_warning(text):
    notifier.send(
        f"⚠️ WARNING\n\n{text}"
    )

def notify_success(text):
    notifier.send(
        f"✅ SUCCESS\n\n{text}"
    )

Интеграция со Slack

Telegram далеко не всегда является корпоративным стандартом.

Например:

requests.post(
    webhook_url,
    json={
        "text": message,
    },
)

Slack Incoming Webhook реализуется буквально несколькими строками.

Интеграция с Microsoft Teams

Похожим образом работает Teams.

requests.post(
    teams_webhook,
    json={
        "text": message,
    },
)

Именно поэтому имеет смысл проектировать notifier как абстракцию.

Универсальный интерфейс уведомлений

Например:

class BaseNotifier:

    def send(
        self,
        text,
    ):
        raise NotImplementedError

Telegram:

class TelegramNotifier(
    BaseNotifier
):
    ...

Slack:

class SlackNotifier(
    BaseNotifier
):
    ...

Тогда основной код не зависит от конкретного канала доставки.

Интеграция с SIEM

На определённом этапе развития инфраструктуры безопасники обязательно приходят с вопросом:

А можно отправлять события в SIEM?

Ответ:

Конечно.

Например:

requests.post(
    siem_url,
    json={
        "event": "USER_DISABLED",
        "user": username,
        "timestamp": timestamp,
    },
)

В SIEM обычно отправляют:

  • блокировки пользователей;

  • назначения критичных ролей;

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

  • массовые изменения;

  • подозрительные события.

Интеграция с Jira и ServiceNow

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

Например:

User disabled
↓
Создать инцидент
↓
Назначить ответственному
↓
Закрыть после проверки

Тогда sync-сервис может автоматически создавать задачи.

Например:

requests.post(
    jira_url,
    json=payload,
)

Метрики Prometheus

Уведомления хороши.

Но их недостаточно.

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

Например:

sync_runs_total
sync_failures_total
users_created_total
users_disabled_total
roles_assigned_total

Это позволяет строить красивые дашборды в Grafana.

И получать алерты ещё до того, как кто-то заметит проблему.

Что действительно стоит отправлять

После нескольких лет эксплуатации обычно формируется следующий список.

Уведомлять о:

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

  • массовых изменениях;

  • отключении пользователей;

  • назначении критичных ролей;

  • завершении синхронизации при ошибках.

Не уведомлять о:

  • каждом обновлении пользователя;

  • каждом изменении почты;

  • каждом назначении обычных ролей.

Потому что люди очень быстро перестают замечать шум.

Production best practices

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

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

  • поддерживайте несколько каналов доставки;

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

  • разделяйте уровни важности;

  • не превращайте чат в поток спама;

  • отправляйте события в SIEM;

  • экспортируйте метрики;

  • тестируйте уведомления регулярно;

  • храните токены в Secret или Vault.

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

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

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

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

Теперь он умеет:

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

  • интегрироваться со Slack и Microsoft Teams;

  • публиковать события в SIEM;

  • создавать задачи во внешних системах;

  • различать уровни критичности;

  • предупреждать о массовых изменениях;

  • сообщать об ошибках синхронизации;

  • экспортировать метрики для Prometheus.

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

Dry-run, аудит и защита от катастроф

Если спросить инженеров, что является самым страшным сценарием при автоматизации управления доступами, многие ответят:

Утечка учётных данных.

Кто-то вспомнит компрометацию Keycloak.

Кто-то расскажет историю о том, как LDAP внезапно перестал отвечать.

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

Она выглядит примерно так:

python sync.py

Именно после запуска очередного "безобидного" скрипта инженеры внезапно обнаруживают, что:

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

  • удалены группы;

  • исчезли роли;

  • отключены подрядчики;

  • отвалился доступ в production;

  • Telegram взорвался сотнями уведомлений.

Самое неприятное заключается в том, что во многих случаях причиной становится вовсе не злоумышленник.

А обычная человеческая ошибка.

Именно поэтому production-системы управления идентификацией обязаны обладать тремя важнейшими свойствами:

  • возможностью безопасного предварительного просмотра изменений;

  • полноценным аудитом;

  • встроенной защитой от катастрофических сценариев.

Почему автоматизация опасна

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

Она позволяет совершать ошибки невероятно эффективно.

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

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

Например:

Ошибка LDAP-фильтра
↓
FreeIPA вернула 0 пользователей
↓
Sync решил, что все сотрудники уволены
↓
Keycloak отключил всех
↓
Понедельник перестал быть добрым

Подобные истории происходят значительно чаще, чем принято думать.

Поэтому первое правило безопасной автоматизации звучит так:

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

Dry-run - страховочный трос инфраструктурного инженера

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

Dry-run отвечает на простой вопрос:

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

Без внесения каких-либо изменений.

Например:

python sync.py --dry-run

Результат:

DRY RUN

Users to create:
- petrov
- sidorov

Users to update:
- ivanov

Users to disable:
- contractor01

Roles to assign:
- ivanov → grafana-admin
- petrov → argocd-readonly

Roles to remove:
- sidorov → harbor-admin

Никаких изменений при этом не происходит.

Реализация dry-run

Во многих проектах dry-run превращается в набор условий:

if dry_run:
    print(...)
else:
    do_action(...)

Но довольно быстро код начинает выглядеть так:

if dry_run:
    ...
else:
    ...

через каждые пять строк.

Поэтому лучше выделить единый механизм.

Например:

class ActionExecutor:

    def __init__(
        self,
        dry_run=False,
    ):
        self.dry_run = dry_run

    def execute(
        self,
        description,
        callback,
    ):

        if self.dry_run:

            logger.info(
                "DRY RUN: %s",
                description,
            )

            return

        callback()

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

executor.execute(
    f"Create user {user.username}",
    lambda: keycloak.create_user(user),
)

Dry-run как обязательный этап CI/CD

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

Pull Request
↓
CI запускает dry-run
↓
Формируется отчёт
↓
Review
↓
Merge
↓
Production

Например:

Expected changes:

Create:
3 users

Disable:
1 user

Assign roles:
7 operations

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

Аудит - память системы

Представим ситуацию.

Безопасник приходит с вопросом:

Почему пользователь ivanov получил доступ администратора Grafana?

Если ответ выглядит так:

Наверное, кто-то что-то нажал.

то аудит вы не прошли.

Любое изменение должно быть объяснимым.

Какие события нужно сохранять

Минимальный набор:

  • запуск синхронизации;

  • завершение синхронизации;

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

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

  • блокировка;

  • разблокировка;

  • создание групп;

  • удаление групп;

  • назначение ролей;

  • отзыв ролей;

  • ошибки;

  • массовые операции.

Например:

2026-06-15 12:00:01
SYNC_STARTED
2026-06-15 12:00:04
USER_CREATED
petrov
2026-06-15 12:00:05
ROLE_ASSIGNED
ivanov
grafana-admin
2026-06-15 12:00:06
SYNC_COMPLETED

Структурированный аудит

Простой текст удобен людям.

Но неудобен системам анализа.

Лучше использовать структурированный формат.

Например:

{
  "timestamp": "2026-06-15T12:00:05Z",
  "event": "ROLE_ASSIGNED",
  "username": "ivanov",
  "role": "grafana-admin"
}

Подобные события прекрасно индексируются в:

  • OpenSearch;

  • Elasticsearch;

  • Splunk;

  • Loki;

  • SIEM.

audit.py

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

import json
import logging
from datetime import datetime

logger = logging.getLogger(
    "audit"
)

def audit(
    event,
    **kwargs,
):
    payload = {
        "timestamp": (
            datetime.utcnow()
            .isoformat()
        ),
        "event": event,
        **kwargs,
    }

    logger.info(
        json.dumps(payload)
    )

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

audit(
    "USER_DISABLED",
    username="ivanov",
)

Результат:

{
  "timestamp": "2026-06-15T12:00:05",
  "event": "USER_DISABLED",
  "username": "ivanov"
}

Защита от катастроф

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

Представим ситуацию.

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

Наш reconciliation увидит:

FreeIPA:
0 пользователей

Keycloak:
1473 пользователя

И сделает вполне логичный вывод:

Все сотрудники уволились.

После чего начнёт отключать пользователей.

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

Предохранитель №1. Максимальное количество изменений

Введём ограничение.

Например:

MAX_DISABLES = 10

Если превышен порог:

if (
    len(users_to_disable)
    > MAX_DISABLES
):

    raise RuntimeError(
        (
            "Refusing to disable "
            f"{len(users_to_disable)} users"
        )
    )

Результат:

Expected maximum: 10
Actual: 347

Sync aborted.

Предохранитель №2. Минимальный размер LDAP

Если вчера было:

1473 пользователей

а сегодня:

3 пользователя

это повод насторожиться.

Например:

MIN_EXPECTED_USERS = 100

if (
    len(freeipa_users)
    < MIN_EXPECTED_USERS
):

    raise RuntimeError(
        (
            "LDAP returned "
            "unexpectedly few users"
        )
    )

Предохранитель №3. Процент изменений

Более гибкий подход.

Например:

disable_ratio = (
    len(users_to_disable)
    / len(existing_users)
)

if disable_ratio > 0.05:

    raise RuntimeError(
        (
            "Disable ratio exceeded "
            "5%"
        )
    )

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

Предохранитель №4. Режим подтверждения

Для особо чувствительных операций можно потребовать подтверждение.

Например:

python sync.py \
  --approve-disable

Без флага:

Mass disable detected.

Run again with:
--approve-disable

Предохранитель №5. Белый список

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

Например:

protected_users:

  - admin
  - breakglass
  - emergency-admin

Проверка:

if username in protected_users:

    logger.warning(
        "Protected user skipped"
    )

    continue

Break Glass Accounts

Отдельно стоит упомянуть аварийные учётные записи.

Практически в каждой зрелой инфраструктуре существуют аккаунты вида:

breakglass
emergency-admin
platform-root

Их используют:

  • при отказе LDAP;

  • при отказе Keycloak;

  • во время аварий;

  • при компрометации.

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

Что делать при обнаружении катастрофического сценария

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

Dry-run обнаружил аномалию
↓
Sync аварийно завершился
↓
Telegram отправил уведомление
↓
Создан инцидент
↓
Инженер проверил FreeIPA
↓
После подтверждения выполнен повторный запуск

Именно поэтому автоматизация не должна быть безусловной.

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

Immutable Audit

Ещё одна интересная практика.

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

Например:

Sync Service
↓
Loki
↓
Object Storage

или:

Sync Service
↓
SIEM
↓
WORM Storage

Это защищает аудит от случайного удаления.

Метрики безопасности

Полезно экспортировать следующие показатели:

sync_runs_total
sync_failures_total
users_disabled_total
mass_disable_attempts_total
dry_run_total
audit_events_total

На их основе можно строить алерты.

Например:

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

Production best practices

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

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

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

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

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

  • контролируйте процент массовых операций;

  • защищайте аварийные учётные записи;

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

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

  • документируйте порядок действий при катастрофах.

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

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

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

К этому моменту наш IAM-контроллер научился не только автоматически управлять доступами, но и делать это безопасно.

Теперь он умеет:

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

  • формировать полноценный аудит;

  • сохранять историю всех операций;

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

  • обнаруживать аномальные сценарии;

  • ограничивать массовые изменения;

  • защищать критические учётные записи;

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

  • уведомлять ответственных о подозрительных событиях.

И именно здесь заканчивается история про "скрипт синхронизации" и начинается история про зрелую систему управления идентификацией, которая не только автоматизирует рутину, но и умеет защищать инфраструктуру от собственных ошибок. Потому что в мире IAM главная угроза далеко не всегда приходит извне. Иногда она запускается командой python sync.py с самыми благими намерениями.

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

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