⟨/⟩ 40_Arch/Tech/CSharp/ADR_SRV_006_AuthImplementation.cs

214 строк · в начало

using System;
using Ban.Sdaid.Notation.Documents;
using Ban.Sdaid.Icd;

namespace Ban.Sdaid.Icd.Arch.Tech.CSharp
{
    /// <summary>
    /// ADR-SRV-006: реализация аутентификации и энфорса ABAC на бэкенде. Фиксирует схему токена
    /// (JWT + jti), где и как проверяются права (базовый хендлер по имени CQRS-класса), как собирается
    /// и кэшируется контекст пользователя, как устроены вход/выход и отзыв токена. Периметр — вне охвата.
    /// </summary>
    public class ADR_SRV_006_AuthImplementation : IAdrDocument, IFolder<ArchCSharpFolder>
    {
        public string Name => "ADR-SRV-006. Аутентификация и энфорс ABAC на бэкенде";

        public string Description =>
            @"Бэкенд-реализация решения о доступе (ADR-011): вход по логину/паролю выдаёт JWT с уникальным jti,
контекст пользователя (claims) собирается из БД и кэшируется по jti, а право на операцию проверяется в
базовом хендлере по имени класса команды/запроса. Выход и отзыв — через список инвалидированных токенов.
Фиксируем схему токена, точку энфорса, доменный модуль доступа и EF-раскладку. Периметр не вводится.";

        public string Version => "0.1";
        public string Status => "proposed";
        public string[] Comments => new[]
        {
            "2026-08-20. Backend-сторона ADR-011 (ABAC). Адаптировано из rtfm ADR-SRV-023 без периметра; пароль на dev — открытым текстом (хеш отложен).",
        };

        public Type? Supersedes => null;

        public static string S1_Context = $"""
            ## Контекст и постановка задачи

            {nameof(Ban.Sdaid.Icd.Arch.Adr.ADR_011_AbacAuthorization)} выбрал ABAC на функциональных claims
            (claims → функции → группы, без периметра). Нужно зафиксировать, **как это работает на бэкенде**:

            - какой схемой аутентифицируется запрос и что несёт токен;
            - где именно проверяется право на команду/запрос (чтобы покрыть все операции, а не расставлять
              атрибуты вручную);
            - как из токена получается контекст пользователя (его claims) и как он кэшируется;
            - как устроены вход, выход и отзыв токена;
            - где живёт доменный модуль доступа и как он ложится на EF.

            Точка встраивания уже есть: операции проходят через процессор и **базовые хендлеры**
            ({nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_001_Cqrs)}) — это естественное место для проверки
            прав. Сейчас аутентификации нет: `CreatedByUserId` пишется как `Guid.Empty`
            ({nameof(Ban.Sdaid.Icd.Arch.Adr.ADR_004_CreatorVsAssignee)}), pipeline ещё не введён.
            """;

        public static string S2_DecisionDrivers = $"""
            ## Драйверы решения

            1. **Сплошное покрытие.** Право проверяется у каждой команды/запроса автоматически, включая
               будущие — без ручной разметки endpoint-ов.
            2. **Минимум состояния на сервере.** JWT + кэшируемый контекст; БД трогаем на входе/выходе и при
               промахе кэша.
            3. **Настоящий выход.** Возможность отозвать токен до истечения (logout) — через чёрный список.
            4. **Совместимость с CQRS-конвейером** ({nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_001_Cqrs)})
               и генераторами команд/запросов (skills `new-command`/`new-query`).
            5. **Конфиг и секреты по средам** ({nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_004_AppConfigAndCors)}):
               подпись токена — секрет вне git.
            6. **Единые правила сущностей/EF** ({nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_003_EntityAndEfMapping)}).
            """;

        public static string S3_ConsideredOptions = """
            ## Рассмотренные варианты

            - **A. `[Authorize]`-атрибуты и политики на контроллерах/действиях.** Штатно для ASP.NET, но
              разметка ручная: новую ручку легко забыть закрыть, а гранулярность «по команде» выражается
              политиками неудобно.
            - **B. Проверка только в middleware.** Централизованно, но middleware не знает, какая именно
              CQRS-операция выполняется, — нет гранулярности «по классу команды/запроса».
            - **C. Энфорс в базовом хендлере по имени CQRS-класса (выбрано).** Проверка стоит ровно там, где
              исполняется операция; покрывает все команды/запросы автоматически; имя claim = имя класса.
            """;

        public static string S4_DecisionOutcome = """
            ## Решение

            Выбран **Вариант C**. Аутентификация — JWT Bearer; авторизация — claim-проверка в базовом хендлере.

            ### Схема токена

            - **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).

            ### Pipeline в `Program.cs`

            ```csharp
            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]` — вход.

            ### Энфорс права (базовый хендлер)

            Базовые классы `IcdBaseCommandHandler` / `IcdBaseQueryHandler` перед выполнением спрашивают
            `AbacService`:

            ```csharp
            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`.

            ### Контекст пользователя и кэш

            - `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`.

            ### Вход / выход / отзыв

            - **Вход** (`LoginByPasswordHandler`): проверить логин/пароль → сгенерировать `jti` (UUID v7) и JWT →
              записать `UserActiveToken` (UserId, Jti, IssuedAt, ExpiresAt) → вернуть токен. Истёкшие active-записи
              подчищаются.
            - **Выход** (`LogoutHandler`): по `jti` из токена → перенести в `UserInvalidatedToken` (чёрный список),
              убрать из active, вытеснить контекст из кэша.
            - **Middleware чёрного списка**: для не-`[AllowAnonymous]` запросов — если `jti` в
              `UserInvalidatedToken`, ответ **401**.

            ### Доменный модуль доступа (`50_Domain/Access/`)

            Сущности (без инфраструктурного префикса, группировка папкой — как `Capture/`):
            `User`, `Function`, `Group`, `Claim` (справочник ключей), `UserGroup`, `GroupFunction`,
            `FunctionClaimValue`, `UserActiveToken`, `UserInvalidatedToken`. EF-конфигурации — в Infrastructure по
            ADR-SRV-003; отдельная миграция. Сид:
            функции + claim-значения сеет разработчик; dev-пользователь-администратор (break-glass) проходит все
            проверки (профиль отдаёт `*/*`), на проде убирается.

            ### Заполнение полей пользователя

            `CreatedByUserId` / `AssignedToUserId` (ADR-004) заполняются из `IUserContext.UserId`, а не заглушкой.

            ### Периметр — вне охвата

            Фильтрация данных по орг-структуре не вводится (в домене нечего скоупить). Точка расширения: при
            появлении признака разграничения добавляется отдельным решением, не затрагивая токен/контекст/энфорс.
            """;

        public static string S5_PositiveConsequences = """
            ## Положительные следствия

            - Каждая команда/запрос закрыта правом автоматически — новую операцию нельзя «забыть» защитить.
            - Права в БД, человекочитаемо администрируются; токен остаётся тонким.
            - Настоящий logout: отозванный токен отклоняется до истечения.
            - Контекст кэшируется по jti — БД не дёргается на каждый запрос.
            """;

        public static string S6_NegativeConsequences = """
            ## Отрицательные следствия и компромиссы

            - Кэш контекста по jti требует инвалидации при изменении полномочий; между несколькими инстансами
              локальный `IMemoryCache` не согласуется (мульти-инстанс — отдельно).
            - Проверка в базовом хендлере обязывает операции, обходящие базовый класс, проверять право вручную.
            - Чёрный список требует обращения к БД/кэшу на каждый защищённый запрос (митигируется кэшем).
            - Доменный `Claim` требует алиаса в двух auth-файлах (осознанно, ради чистых имён в домене).
            """;

        public static string S7_Validation = """
            ## Проверка

            - Запрос без токена к защищённой ручке → 401 (fallback policy).
            - Пользователь без claim на команду → 403 при её вызове.
            - После logout запрос с тем же токеном → 401 (чёрный список).
            - Новый command-хендлер, унаследованный от базового, закрыт правом без дополнительной разметки.
            - `CreatedByUserId` созданного пакета = id вошедшего пользователя, не `Guid.Empty`.
            """;

        public static string S8_OpenQuestions = """
            ## Открытые вопросы / отложено

            - **Хеширование пароля.** На dev сид-пароль в открытом виде; до продакшна — хеш + соль.
            - **OAuth2 / OIDC, refresh-токен** — позже, отдельными решениями.
            - **Инвалидация кэша между инстансами** — для мульти-инстанса нужен внешний сигнал (шина/Outbox).
            - **Периметр** — вводится при появлении признака разграничения (см. ADR-011, «вне охвата»).
            """;

        public static string S99_Related = $"""
            ## Связанные артефакты

            - {nameof(Ban.Sdaid.Icd.Arch.Adr.ADR_011_AbacAuthorization)} — системное решение (ABAC), сторону которого реализует этот ADR.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_001_Cqrs)} — базовые хендлеры как точка энфорса.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_003_EntityAndEfMapping)} — правила сущностей и EF для модуля `Access/`.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_004_AppConfigAndCors)} — signing key и параметры токена как конфиг/секреты.
            - {nameof(Ban.Sdaid.Icd.Arch.Adr.ADR_004_CreatorVsAssignee)} — поля пользователя, заполняемые из контекста.
            - UI-сторона: {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_010_Auth)} (аутентификация), {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_011_Claims)} (claims на UI).
            - Домен `50_Domain/Access/`, endpoints `45_Api/Endpoints/Auth/`, хендлеры `55_Backend/Handlers/Auth/` — оформляются отдельной задачей.
            """;
    }
}