ESPNWho's on the CFP bubble? Did Week 8 burst the Big 12's two-bid dreams?Bollywood HungamaAli Abbas Zafar wraps up shooting for Ahaan Panday-Sharvari starrer Beparwah; says returning to YRF feels like “coming back home”Daily MaverickWINNING: Anna Trapido is Food Writer of the YearThe Jerusalem PostIsraeli Hollywood star Gal Gadot votes in 26th Israeli Knesset electionsPunchFRSC sends 19,560 number plates to FCT after delay complaintsUN NewsKenya: 20 Ebola contacts released from quarantineInquirerEstrada’s camp refutes Bonoan’s P7-B insertion claim anewThe Straits TimesDinner will never be Over at competitive eater Zermatt Neo’s new restaurantCapital FMFormer Kiambu governor Waititu, co-accused plead for leniency in sentencingZDF heuteEntdecken Sie das ZDF-NachrichtenstudioGhaflaRuto Breaks Ground on KSh70 Billion Cement Plant in KituiBBC NewsUK and EU officials to discuss freedom of movement
The Daily Newsstand · Free, Always
Tuesday, October 27, 2026

DDD в веб-консоли для LDAP: что сработало, что пришлось выкинуть и почему границы слоёв теперь проверяют тесты

Translate

Axia — open-source веб-консоль и REST API для Active Directory и OpenLDAP на .NET 10 и React. Про то, как там чуть не дали хелпдеску сбросить пароль администратору, я уже писал. Эта статья про архитектуру.

Проект начинался с классического набора: Domain, Application на MediatR, Infrastructure, Api. Агрегаты, value objects, доменные события, репозитории, всё как в туториалах. Через несколько раундов ревью безопасности от этого набора осталась примерно половина. Ниже про то, какая именно и почему.

Главная странность: данные не наши

В обычном CRUD-приложении источник истины — своя база. У нас источник истины — чужой каталог. Пользователи, группы, OU и компьютеры живут в AD или OpenLDAP, и правила для них придумал не я. Уникальность логина, парольная политика, схема атрибутов — всё это проверяет сервер каталога, и проверяет по-своему в каждой инсталляции.

Из этого следует неприятная вещь: агрегат User с инвариантами тут фикция. Если я напишу в домене «пароль не короче 8 символов», а в домене заказчика политика 14, мой инвариант просто врёт. А если наоборот, он мешает людям работать.

В итоге пользователь в домене — это LdapUserInfo, честная read-model с init-свойствами: DN, логин, имя, почта, группы, уже приведённые к одним именам для AD и OpenLDAP. Никакого поведения. Богатые сущности остались только там, где мы сами хозяева данных: профиль подключения к каталогу (Provider), сопоставление группы роли (RoleMapping), права ролей, лимиты. Это таблицы в нашей базе, и тут DDD помог по-настоящему.

То, что точно окупилось с первого дня, — порты и адаптеры. Каталогов два вида, библиотек тоже две (Novell LDAP и System.DirectoryServices), и всё, что отличает один каталог от другого — фильтры, экранирование, имена атрибутов, способ записи пароля, пейджинг, — сидит в провайдерах за ILdapService. Application не знает, с чем разговаривает. Тесты подменяют LDAP на границе провайдера, а в CI есть отдельный прогон против живого Samba DC.

Боль первая: анемичный Provider

Первая версия Provider была мешком публичных сеттеров. Собирали его в четырёх местах: два хэндлера (создание и изменение), сидер и репозиторий, который копировал поля одно за другим. Правило, добавленное в один хэндлер, тихо отсутствовало во втором. Ничто не мешало доехать до LdapConnection пустому хосту, размеру страницы 0 (на одних серверах это «без пейджинга», на других ошибка) или фильтру поиска без плейсхолдера.

Сейчас сеттеры приватные, а состояние меняется только методами, названными по тому, что делает администратор. Create вызывает ровно те же методы, что и редактирование, поэтому правило живёт в одном месте:

public static Provider Create(string? name, string? host, LdapPort port, LdapSecurity security, /* ... */)
{
    var provider = new Provider { Id = Guid.NewGuid() };

    provider.Rename(name);
    provider.PointAt(host, port, security, failoverHosts);
    provider.UseSearchRoot(baseDn);
    provider.RotateAdminPassword(encryptedAdminPassword);
    provider.ConfigureUserSearch(userSearchFilter, userSearchPageSize);
    // ...
    return provider;
}

Цена тоже есть, и скрывать её не буду. Create принимает 12 параметров. Ради EF есть приватный конструктор с комментарием «только для материализации». Для старых строк в базе, которые новые правила уже не прошли бы, пришлось продумывать, что с ними делать. Но я бы снова так сделал: это настройки, от которых зависит, кто войдёт в систему.

Самое полезное из DDD: value objects

Если бы меня попросили оставить одну идею из всего DDD, я бы оставил их. Три примера, и все три выросли из багов.

LdapSearchFilter. Шаблон фильтра поиска пользователя был просто строкой, которую администратор вводит в настройках, и она шла прямо в string.Format. Шаблон без {0} искал сам себя и молча не находил никого. Шаблон с {1} кидал FormatException изнутри вызова каталога, и пользователь получал 500 на логине. Несбалансированные скобки сервер отвергал как ошибку протокола. Все три — свойства самого шаблона, и теперь их проверяет тип, а не четыре места, где его форматируют. При этом тип специально не экранирует подставляемый логин: шаблон — это конфигурация, логин — ввод пользователя, и защищают их разные вещи.

EncryptedSecret. Пароль сервисной учётки каталога хранится зашифрованным, но даже шифротекст не должен попадать в логи. Секреты утекают не потому, что кто-то решил их залогировать, а потому, что кто-то написал $"provider {p.Name} pwd {p.EncryptedAdminPassword}", отлаживая совсем другое. Теперь ToString() возвращает ***, а настоящее значение достаётся только через Reveal(). Этот вызов виден на ревью, находится грепом и встречается ровно в двух местах: где шифротекст отдают на расшифровку и в конвертере EF. Само шифрование (AES-GCM) осталось в Infrastructure, домен ключа не видит.

DistinguishedName. Выглядит как обёртка над строкой, но в ней сидят две вещи, на которых я спотыкался. Сравнение без учёта регистра, потому что AD возвращает DN одной и той же группы в разном регистре в разных операциях. И разбор с учётом экранирования: обычный Split(',') читал CN=Admins\, Tier 0,OU=Groups как группу с именем Admins\. Это имя чужой группы, и она вполне может существовать.

Отдельно стоит RoleMapping. Раньше группа в сопоставлении задавалась по CN, то есть по имени. Но CN уникален только внутри своего контейнера, и маппинг Administrators → Admin давал роль членам любой группы с таким именем. В том числе созданной сегодня в OU, где её автор может создавать группы и положить туда себя. Теперь маппинг пишется по DN. Старые строки по имени продолжают работать, иначе после обновления все админы потеряли бы роль и вернуть её было бы некому. Но сущность помечает их флагом IdentifiesGroupByNameOnly, консоль их подсвечивает, а редактирование такой строки требует указать DN.

Здесь я понял вещь, которую в книжках про DDD обычно не пишут: в приложении про доступ аккуратная модель типов — это и есть безопасность. Ошибка модели тут не кривая цифра в отчёте, а чужой человек с ролью Admin.

Боль вторая: аудит на доменных событиях

Это то, от чего я в итоге почти отказался. Идея была красивая: операция публикует факт (UserCreated, UserUpdated, успешный и неуспешный вход), подписчик пишет его в журнал аудита. Операция не знает про аудит, всё слабо связано.

На практике выяснилось два момента. События были только на четыре операции. Удаление учёток, массовые операции со сбросом паролей, группы, OU, профили подключения, маппинги ролей не оставляли в журнале ничего. Никто не забыл специально, просто ничто не заставляло публиковать событие. А второе ещё хуже: UserCreated несёт только того, кого создали, и в колонке «кто сделал» оказывался сам созданный пользователь, а не человек, который его завёл. Для журнала аудита это приговор.

Решение оказалось скучным: аудит стал поведением в пайплайне MediatR. Команда помечается интерфейсом IAuditedCommand и говорит, что писать в журнал (цель и описание изменения, без секретов). А тест падает, если хоть одна команда эту метку не носит. Новую команду без аудита просто не получится добавить. Поведение стоит снаружи авторизации, поэтому в журнал попадают и отклонённые попытки:

LoggingBehaviour → AuditBehaviour → AuthorizationBehaviour → ValidationBehaviour → handler

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

Мораль: события хороши, когда подписчик опционален. Аудит опциональным не является, и держать его на добросовестности каждого хэндлера нельзя.

Боль третья: пайплайн, который можно обойти

Раз валидация, проверка прав и аудит живут в пайплайне, то всё, что вызывает хэндлер мимо MediatR, обходит все три. А хэндлеры были public просто потому, что так генерирует IDE. Команды тоже были публичными: контроллер сам собирал команду и делал Send, то есть Api знал внутренние типы Application и то, что под капотом MediatR.

[Мем Drake — заменить картинкой] Сверху, Drake отворачивается: await sender.Send(command). Снизу, Drake доволен: new CreateUserCommandHandler(...).Handle(command). Подпись: «валидация? права? аудит? не слышал».

Сделал так. Между Api и Application появились модули (IUsersModule, IGroupsModule и т.д.) в отдельной сборке Contracts. Модуль ничего не решает: превращает запрос в команду, отправляет и возвращает ответ. Хэндлеры, валидаторы, behaviours и сами команды стали internal. И, что важнее, это проверяет тест, а не договорённость:

[Fact]
public void NoRequestHandler_IsReachableFromOutsideTheLayer()
{
    var exposed = ApplicationTypes
        .Where(type => type is { IsClass: true, IsPublic: true })
        .Where(type => type.GetInterfaces().Any(i =>
            i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>)))
        .Select(type => type.Name)
        .ToList();

    Assert.Empty(exposed);
}

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

Рядом была проблема помельче, но очень типичная. Infrastructure в своём AddApplicationServices регистрировал внутренние сервисы Application, и из-за этого они обязаны были быть публичными. То есть нижний слой знал, как устроен верхний. Теперь Application собирает себя сам одним AddApplicationLayer(), а Infrastructure регистрирует только свои адаптеры.

Боль четвёртая: самое важное правило не помещается в домен

Самая серьёзная дыра за всё время была такая: право users.update говорит, какую правку можно сделать, но не говорит, кому. А в каталог мы пишем от сервисной учётки, которая может поменять кого угодно. В итоге роль UserEditor из коробки могла сбросить пароль администратору и зайти под ним, а groups.manage — добавить себя в админскую группу.

Правило «нельзя трогать того, кто сильнее тебя» звучит как идеальный кандидат в доменный сервис. Но чтобы его проверить, нужно сходить в каталог за группами цели, в базу за маппингами и рассчитать права обеих сторон, а ещё посмотреть adminCount в AD. Домену без IO такое не по силам. Поэтому PrivilegeGuard живёт в Application, и я с этим смирился. В домене осталось то, что можно посчитать чисто: каталог прав, value objects, генератор логинов с транслитерацией.

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

И про MediatR

Маленькое отступление. С версии 13 MediatR больше не Apache-2.0: теперь это RPL-1.5 или коммерческая лицензия с ключом. Dependabot принёс бы это как обычный апдейт версии, поэтому мы запретили ему предлагать 13.x и сидим на 12.5. Хорошая новость в том, что после введения модулей MediatR виден только внутри Application. Если когда-нибудь будем с него уходить, затронем один проект, а не каждый контроллер.

Что получилось

Api ──► Application ──► Domain
 │           ▲
 └──► Infrastructure ──┘        (все знают Contracts)

Слой

Что в нём

Строк

Domain

сущности, которыми мы владеем; value objects; каталог прав; чистые сервисы

~2,4 тыс.

Application

команды и запросы, пайплайн, PrivilegeGuard, модули

~6,5 тыс.

Infrastructure

LDAP-провайдеры, EF Core, шифрование, JWT, фоновые задачи

~6,3 тыс.

Api

контроллеры, middleware, rate limiting

~3,6 тыс.

Tests

959 тестов, включая архитектурные

~15 тыс.

Домен самый маленький из слоёв, и это честно отражает предметную область: большая часть сложности — это интеграция с каталогами и правила доступа, а не богатая модель. Один только StandardLdapProvider занимает около 2000 строк.

Если бы я начинал заново, сделал бы так:

  1. Сначала понять, какими данными приложение владеет, а какие только читает и пишет в чужую систему. Агрегаты только для первых.

  2. Вводить value object сразу, как только строка начинает носить смысл (DN, фильтр, секрет). Это самая дешёвая и самая окупающаяся часть DDD.

  3. Сквозные обязанности (аудит, права, валидация) делать пайплайном с маркерами, а не событиями, которые нужно не забыть опубликовать.

  4. Границы слоёв проверять тестами с первого дня. internal по умолчанию и рефлексия в тестах стоят копейки по сравнению с тем, чтобы потом искать, кто звал хэндлер напрямую.

И писать в комментариях к классу, как было раньше и почему стало иначе. Половина этой статьи собрана именно из таких комментариев, и без них я бы уже не вспомнил, зачем у Provider приватные сеттеры.

Код открыт под Apache 2.0: github.com/Nelsar/Axia. Интересно услышать, как вы моделируете данные, которые вам не принадлежат: тоже держите их read-model или всё-таки строите агрегаты?

Черновик статьи подготовлен с помощью нейросети по коду и комментариям в репозитории; факты и выводы проверены автором.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.