ADR-SRV-006. Аутентификация и энфорс ABAC на бэкенде
Бэкенд-реализация решения о доступе (ADR-011): вход по логину/паролю выдаёт JWT с уникальным jti, контекст пользователя (claims) собирается из БД и кэшируется по jti, а право на операцию проверяется в базовом хендлере по имени класса команды/запроса. Выход и отзыв — через список инвалидированных токенов. Фиксируем схему токена, точку энфорса, доменный модуль доступа и EF-раскладку. Периметр не вводится.
1. Контекст и постановка задачи
ADR_011_AbacAuthorization выбрал ABAC на функциональных claims (claims → функции → группы, без периметра). Нужно зафиксировать, как это работает на бэкенде:
- какой схемой аутентифицируется запрос и что несёт токен;
- где именно проверяется право на команду/запрос (чтобы покрыть все операции, а не расставлять атрибуты вручную);
- как из токена получается контекст пользователя (его claims) и как он кэшируется;
- как устроены вход, выход и отзыв токена;
- где живёт доменный модуль доступа и как он ложится на EF.
Точка встраивания уже есть: операции проходят через процессор и базовые хендлеры
(ADR_SRV_001_Cqrs) — это естественное место для проверки
прав. Сейчас аутентификации нет: CreatedByUserId пишется как Guid.Empty
(ADR-004 «Кто ввёл и кто взял в работу»), pipeline ещё не введён.
2. Драйверы решения
- Сплошное покрытие. Право проверяется у каждой команды/запроса автоматически, включая будущие — без ручной разметки endpoint-ов.
- Минимум состояния на сервере. JWT + кэшируемый контекст; БД трогаем на входе/выходе и при промахе кэша.
- Настоящий выход. Возможность отозвать токен до истечения (logout) — через чёрный список.
- Совместимость с CQRS-конвейером (ADR_SRV_001_Cqrs)
и генераторами команд/запросов (skills
new-command/new-query). - Конфиг и секреты по средам (ADR_SRV_004_AppConfigAndCors): подпись токена — секрет вне git.
- Единые правила сущностей/EF (ADR_SRV_003_EntityAndEfMapping).
3. Рассмотренные варианты
- A.
[Authorize]-атрибуты и политики на контроллерах/действиях. Штатно для ASP.NET, но разметка ручная: новую ручку легко забыть закрыть, а гранулярность «по команде» выражается политиками неудобно. - B. Проверка только в middleware. Централизованно, но middleware не знает, какая именно CQRS-операция выполняется, — нет гранулярности «по классу команды/запроса».
- C. Энфорс в базовом хендлере по имени CQRS-класса (выбрано). Проверка стоит ровно там, где исполняется операция; покрывает все команды/запросы автоматически; имя claim = имя класса.
4. Решение
Выбран Вариант C. Аутентификация — JWT Bearer; авторизация — claim-проверка в базовом хендлере.
4.1. Схема токена
- JWT, подпись HMAC-SHA256.
MapInboundClaims = false(не переименовывать типы claim-ов). - Токен несёт минимум:
jti(уникальный id токена),IcdUserId,IcdUserLogin. Права в токен не кладутся — они собираются из БД в контекст (см. ниже). - Валидация: issuer, audience, lifetime, signing key;
ClockSkew~30 c. - Параметры (issuer/audience/lifetime) — в
appsettings; signing key — секрет (appsettings.Local / генератор конфигов, ADR-SRV-004).
4.2. Pipeline в Program.cs
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => { o.MapInboundClaims = false; o.TokenValidationParameters = ...; });
builder.Services.AddAuthorization(o =>
o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
// ...
app.UseAuthentication();
app.UseAuthorization();
FallbackPolicy закрывает всё по умолчанию; открыто только помеченное [AllowAnonymous] — вход.
4.3. Энфорс права (базовый хендлер)
Базовые классы IcdBaseCommandHandler / IcdBaseQueryHandler перед выполнением спрашивают
AbacService:
if (!await abac.CanCqrsCommandAsync(typeof(TCommand).Name, ct))
throw new ForbiddenException($"Нет права на команду {typeof(TCommand).Name}");
CanCqrsCommandAsync / CanCqrsQueryAsync проверяют, есть ли у пользователя claim с ключом
icd/cqrs/command (или .../query) и значением = имя класса или *. ForbiddenException
маппится в HTTP 403 (обработчик исключений API). Без проверки намеренно — только LoginByPassword,
Logout, GetUserProfile.
4.4. Контекст пользователя и кэш
IUserContext(scoped) —UserId,Login,IsAuthenticated,Claims(ключ → множество значений),HasClaimValue(key, value).UserContextProviderстроит контекст из БД: группы пользователя → функции групп → claim-значения функций, сгруппированные по ключу claim. Кэш —IMemoryCacheпо ключуuserctx:{jti}, TTL = срок жизни токена. Инвалидация: при logout и при изменении полномочий (правка групп/функций пользователя).- Коллизия имени
Claim. ВUserContextProviderиJwtTokenServiceвстречаетсяSystem.Security.Claims.Claim; чтобы не конфликтовать с доменнымClaim, в этих файлах — алиасusing SecurityClaim = System.Security.Claims.Claim;. Прочий код домена импортирует только доменныйClaim.
4.5. Вход / выход / отзыв
- Вход (
LoginByPasswordHandler): проверить логин/пароль → сгенерироватьjti(UUID v7) и JWT → записатьUserActiveToken(UserId, Jti, IssuedAt, ExpiresAt) → вернуть токен. Истёкшие active-записи подчищаются. - Выход (
LogoutHandler): поjtiиз токена → перенести вUserInvalidatedToken(чёрный список), убрать из active, вытеснить контекст из кэша. - Middleware чёрного списка: для не-
[AllowAnonymous]запросов — еслиjtiвUserInvalidatedToken, ответ 401.
4.6. Доменный модуль доступа (50_Domain/Access/)
Сущности (без инфраструктурного префикса, группировка папкой — как Capture/):
User, Function, Group, Claim (справочник ключей), UserGroup, GroupFunction,
FunctionClaimValue, UserActiveToken, UserInvalidatedToken. EF-конфигурации — в Infrastructure по
ADR-SRV-003; отдельная миграция. Сид:
функции + claim-значения сеет разработчик; dev-пользователь-администратор (break-glass) проходит все
проверки (профиль отдаёт */*), на проде убирается.
4.7. Заполнение полей пользователя
CreatedByUserId / AssignedToUserId (ADR-004) заполняются из IUserContext.UserId, а не заглушкой.
4.8. Периметр — вне охвата
Фильтрация данных по орг-структуре не вводится (в домене нечего скоупить). Точка расширения: при появлении признака разграничения добавляется отдельным решением, не затрагивая токен/контекст/энфорс.
5. Положительные следствия
- Каждая команда/запрос закрыта правом автоматически — новую операцию нельзя «забыть» защитить.
- Права в БД, человекочитаемо администрируются; токен остаётся тонким.
- Настоящий logout: отозванный токен отклоняется до истечения.
- Контекст кэшируется по jti — БД не дёргается на каждый запрос.
6. Отрицательные следствия и компромиссы
- Кэш контекста по jti требует инвалидации при изменении полномочий; между несколькими инстансами
локальный
IMemoryCacheне согласуется (мульти-инстанс — отдельно). - Проверка в базовом хендлере обязывает операции, обходящие базовый класс, проверять право вручную.
- Чёрный список требует обращения к БД/кэшу на каждый защищённый запрос (митигируется кэшем).
- Доменный
Claimтребует алиаса в двух auth-файлах (осознанно, ради чистых имён в домене).
7. Проверка
- Запрос без токена к защищённой ручке → 401 (fallback policy).
- Пользователь без claim на команду → 403 при её вызове.
- После logout запрос с тем же токеном → 401 (чёрный список).
- Новый command-хендлер, унаследованный от базового, закрыт правом без дополнительной разметки.
CreatedByUserIdсозданного пакета = id вошедшего пользователя, неGuid.Empty.
8. Открытые вопросы / отложено
- Хеширование пароля. На dev сид-пароль в открытом виде; до продакшна — хеш + соль.
- OAuth2 / OIDC, refresh-токен — позже, отдельными решениями.
- Инвалидация кэша между инстансами — для мульти-инстанса нужен внешний сигнал (шина/Outbox).
- Периметр — вводится при появлении признака разграничения (см. ADR-011, «вне охвата»).
Связанные артефакты
Документы
- ADR ADR-004. Разделение «кто ввёл» и «кто взял в работу»
- ADR ADR-011. Разграничение доступа по модели ABAC (claims → функции → группы)
- ADR ADR-SRV-001. CQRS и обёртка ответа
- ADR ADR-SRV-003. Сущности и EF-маппинг
- ADR ADR-SRV-004. Конфигурация бэка и CORS
- ADR ADR-UI-010. Аутентификация
- ADR ADR-UI-011. Claims и проверка прав на UI