Administrator
  • Joined on 2025-04-12

Ava3v.WebApi.Core (0.10.0)

Published 2026-09-21 22:45:09 +03:00 by Administrator

Installation

dotnet nuget add source --name Administrator --username your_username --password your_token https://git.ava3v.ru/api/packages/Administrator/nuget/index.json
dotnet add package --source Administrator --version 0.10.0 Ava3v.WebApi.Core

About this package

Общее веб-ядро ava3v: minimal-API → MediatR (CQRS) с билдерами (GET/POST/PUT/DELETE/file/form-data), базовые команды/запросы/хендлеры, ValidationBehavior (FluentValidation), единый маппинг ошибок в HTTP, локализация, контексты (correlation/current-user), JWT. Извлечено из WareHub.

Ava3vLibs

Общие .NET-библиотеки ava3v для бэкендов WareHub, Issues, Pfl, Licensing, Cargo. Пять NuGet-пакетов одной версией, приватный фид Gitea. Здесь — как этим пользоваться: от пустого Program.cs до сессий, аудита и серверного грида. История изменений — CHANGELOG.md, сводка ломающих изменений для переезда — docs/ava3v-libs-changes-0.3-0.8.md.

Требования: .NET 10 (Abstractions и Components.Contract собираются и под net8.0), EF Core 10, Postgres 14+ (Npgsql). Sqlite и InMemory — только для тестов.

Содержание

  1. Из чего состоит
  2. Подключение
  3. Быстрый старт: пустое приложение
  4. CQRS и minimal API
  5. Аутентификация: JWT
  6. Сессии и refresh-токены
  7. API-ключи для интеграций
  8. Обслуживание: очистка и retention
  9. Доступ к данным: Ava3v.DataAccess
  10. Аудит: Ava3v.Audit
  11. Серверный грид и дерево: Ava3v.Components.Contract
  12. Мелочи: health, Mapster, корреляция
  13. Справочник appsettings
  14. Справочник кодов ошибок
  15. Грабли
  16. Тесты
  17. Релиз

1. Из чего состоит

Пакет Зависит от Что внутри
Ava3v.Abstractions ничего Контракты без реализации: маркеры сущностей (IHasId, IHasCreated, IHasDeleted, …), EntityBase, PagedResponse<T>, BusinessLogicException/ErrorResponse, ICurrentUserService, права (RequirePermissionAttribute, IAva3vPermissionService), контракты сессий, API-ключей, аудита и обслуживания
Ava3v.DataAccess Abstractions, EF Core Ava3vDbContextBase (метаданные, мягкое удаление, период действия, авторство, snake_case), Ava3vEntityConfiguration<T>, таблицы refresh-токенов и API-ключей
Ava3v.WebApi.Core Abstractions, ASP.NET Core, MediatR, FluentValidation, Mapster, Microsoft.AspNetCore.OpenApi, JwtBearer CQRS-базы и маппинг minimal API → MediatR, ошибки, локализация, права, JWT, сессии, CSRF, CORS, API-ключи, обслуживание, health
Ava3v.Components.Contract Abstractions, EF Core Серверная сторона грида @ava3v/ui-vue: фильтры, сортировка, поиск, группировка, уникальные значения, массовые операции; загрузчик деревьев
Ava3v.Audit WebApi.Core, DataAccess, EF Core Relational Журнал изменений по полям и снимки версий в транзакции сохранения, API истории, retention

Зависимости строго вниз: Audit → WebApi.Core, DataAccess → Abstractions. Продукт обычно берёт все пять; минимальный набор для API без базы — WebApi.Core.

2. Подключение

nuget.config продукта (один раз; чтение фида анонимное, токен не нужен):

<configuration>
  <packageSources>
    <add key="ava3v" value="https://git.ava3v.ru/api/packages/Administrator/nuget/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="ava3v"><package pattern="Ava3v.*" /></packageSource>
    <packageSource key="nuget.org"><package pattern="*" /></packageSource>
  </packageSourceMapping>
</configuration>

Ссылки — одной версией на все пакеты (почему — §17):

<PackageReference Include="Ava3v.Abstractions" Version="0.9.8" />
<PackageReference Include="Ava3v.DataAccess" Version="0.9.8" />
<PackageReference Include="Ava3v.WebApi.Core" Version="0.9.8" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.9.8" />
<PackageReference Include="Ava3v.Audit" Version="0.9.8" />

Страницы пакетов в Gitea: https://git.ava3v.ru/Administrator/-/packages/nuget/<Имя>/<версия>.

3. Быстрый старт: пустое приложение

Полный Program.cs с сессиями, аудитом, гридом и правами. Порядок в конвейере важен, он прокомментирован.

using Ava3v.Abstractions.Sessions;
using Ava3v.Audit;
using Ava3v.Components.Contract;
using Ava3v.DataAccess.Sessions;
using Ava3v.WebApi.Core.Application;
using Ava3v.WebApi.Core.Localization.Extensions;
using Ava3v.WebApi.Core.Web.Extensions;
using Ava3v.WebApi.Core.Web.Extensions.MinimalApi;
using Ava3v.WebApi.Core.Web.Middlewares;
using Ava3v.WebApi.Core.Web.Sessions;

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;

// --- Ядро: локализация, JWT, текущий пользователь, корреляция, IHandlerContext
services.AddAva3vLocalization(configuration);           // Localization:DefaultCulture / SupportedCultures
services.AddAva3vCoreWebApi(configuration);             // секция Jwt; включает AddAva3vJwtAuthentication (повтор безопасен)
services.AddAva3vApplicationAssemblyContaining<CreateItemCommandInfo.Command>(); // MediatR + FluentValidation + ValidationBehavior
services.AddAuthorization();

// --- База: контекст на Ava3vDbContextBase, аудит интерсептором, таблицы либы в OnModelCreating
services.AddAva3vAudit<AppDbContext>(o => o.Schema = "audit");
services.AddScoped<IAuditAccessPolicy, EntityAuditAccessPolicy>();   // обязательно для MapAva3vAudit: кому видна история сущности (§10)
services.AddDbContext<AppDbContext>((sp, o) => o
    .UseNpgsql(configuration.GetConnectionString("Db"))
    .UseAva3vAudit(sp));                                // НЕ AddDbContextPool — §10
services.AddAva3vRefreshTokenStore<AppDbContext>();
services.AddAva3vApiKeyStore<AppDbContext>();

// --- Сессии, ключи, обслуживание, грид, права, CORS
services.AddAva3vSessions(configuration);               // Jwt:Sessions + AuthCookie
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>();       // обязательно: клеймы продукта (§6.2)
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>(); // по желанию: блокировка/активность (без него состояние владельца сессий и API-ключей не проверяется)
services.AddAva3vApiKeys(configuration);                // после AddAva3vCoreWebApi
services.AddAva3vMaintenance(configuration);            // без него таблицы токенов и аудита растут
services.AddAva3vComponentsContract();                  // IDataSourceLoaderAdapter для грида
services.AddAva3vPermissionAuthorization<AppPermissionService>();    // §4.7
services.AddAva3vCors(configuration);                   // только если фронт на другом хосте (§6.6)
services.AddStackExchangeRedisCache(o => o.Configuration = configuration["Redis"]); // несколько экземпляров API
services.AddAva3vHealthChecks();                        // + свои проверки с тегом HealthCheckExtensions.ReadyTag

var app = builder.Build();

app.ConfigureExceptionHandler();                        // BusinessLogicException → статус + ErrorResponse; остальное → 500
app.UseAva3vRequestLocalization(configuration);         // CurrentUICulture по Accept-Language
app.UseRouting();
app.UseAva3vCors();                                     // до аутентификации: отказы 401/403 тоже получают CORS-заголовки
app.UseMiddleware<CorrelationIdMiddleware>();           // X-Correlation-Id в ответе и в логах
app.UseAuthentication();
app.UseAva3vSessions();                                 // необязательно: только предупреждения старта
app.UseAuthorization();
app.UseAva3vPermissionAuthorization();                  // RequirePermission на эндпоинтах, после UseAuthorization

app.MapAva3vHealthChecks();                             // /health/live, /health/ready
app.MapAva3vCsrf();                                     // GET api/auth/csrf (анонимный)
app.MapAva3vAudit();                                    // GET api/audit/getShort; без IAuditAccessPolicy падает на старте (§10)
app.RegisterMinimalApiControllers(typeof(ItemsController).Assembly);

app.Run();

Минимальный appsettings.json для этого файла:

{
  "ConnectionStrings": { "Db": "Host=localhost;Database=app;Username=app;Password=app" },
  "Localization": { "DefaultCulture": "ru-RU", "SupportedCultures": ["ru-RU", "en-US"] },
  "Jwt": { "Key": "<не короче 32 байт, из секретов>", "Issuer": "App", "Audience": "AppClient",
           "AccessTokenLifetimeMinutes": 15, "ExpiredHeaderName": "IS-TOKEN-EXPIRED" },
  "AuthCookie": { "SameSite": "Strict", "Secure": true },
  "Maintenance": { "IntervalMinutes": 60 }
}

Что должен написать продукт, чтобы это заработало: контекст (§9.1), AppClaimsProvider (§6.2), AppPermissionService (§4.7), контроллер с командой (§4). Полный справочник настроек — §13.

4. CQRS и minimal API

Идея: HTTP-эндпоинт — одна строка маппинга, вся логика — в MediatR-хендлере. MVC-контроллеров нет. Swagger-описание берётся из XML-комментариев команды и её свойств, поэтому саммари пишутся содержательно.

4.1 Команда, запрос, хендлер

/// <summary>Информация команды создания номенклатуры</summary>
public static class CreateItemCommandInfo
{
    /// <summary>Создать позицию номенклатуры</summary>
    public sealed class Command : CommandBase<long>            // CommandBase (без результата) / CommandBase<T> / QueryBase<T>
    {
        /// <summary>Наименование</summary>
        [Display(Name = "Наименование")]
        public string Name { get; set; } = string.Empty;

        /// <summary>Артикул</summary>
        public string? Sku { get; set; }
    }

    internal sealed class Validator : AbstractValidator<Command>   // FluentValidation, находится по сборке
    {
        public Validator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
    }

    internal sealed class Handler : CommandHandlerBase<Command, long, AppDbContext>
    {
        public Handler(IHandlerContext handlerContext, AppDbContext context) : base(handlerContext, context) { }

        public override async Task<long> Handle(Command request, CancellationToken cancellationToken)
        {
            if (await Context.Items.AnyAsync(x => x.Sku == request.Sku, cancellationToken))
            {
                // Ошибка бизнес-логики: статус + сообщение + машинный код для фронта
                throw new BusinessLogicException(HttpStatusCode.Conflict,
                    HandlerContext.Localizer.GetStringOrDefault("Item_SkuExists", "Артикул уже используется"),
                    "item_sku_exists");
            }

            var item = new Item { Name = request.Name, Sku = request.Sku };   // CreatedBy/CreatedAt поставит контекст
            Context.Items.Add(item);
            await Context.SaveChangesAsync(cancellationToken);
            return item.Id;
        }
    }
}

Базовые типы:

Тип Когда
CommandBase / ICommandBase команда без результата (ответ 200 без тела)
CommandBase<T> / ICommandBase<T> команда с результатом
QueryBase<T> / IQueryBase<T> запрос (GET, параметры из query и маршрута)
RegistryQuery<TRow> запрос списка: Search, Skip, Take, ответ PagedResponse<TRow>
TreeRegistryQuery<TRow> то же для дерева: плюс ParentId, ParentIds
CommandHandlerBase<TCommand, TResponse, TContext> хендлер: свойства Context и HandlerContext
CommandHandlerVoidBase<TCommand, TContext> хендлер команды без результата
QueryHandlerBase<TQuery, TResponse, TContext> хендлер запроса

IHandlerContext даёт CurrentUserService (кто вызывает) и Localizer (строки SharedResources). Регистрация всей сборки: services.AddAva3vApplicationAssembly(assembly) или AddAva3vApplicationAssemblyContaining<TMarker>() — MediatR, валидаторы и ValidationBehavior. Невалидная команда до хендлера не доходит: 400 с ValidationErrorResponse (errors: { поле: [сообщения] }, ключи в camelCase).

4.2 Контроллер: одна статическая функция

Контроллер — наследник CustomController с public static void Register(IEndpointRouteBuilder app). RegisterMinimalApiControllers(assemblies) находит их и вызывает Register; контроллер без такого метода — исключение на старте (раньше опечатка молча оставляла модуль без роутов).

public sealed class ItemsController : CustomController
{
    public static void Register(IEndpointRouteBuilder app)
    {
        app.MapGetMediatrRequest<GetItemsQueryInfo.Query, PagedResponse<ItemDto>>(cfg => cfg
            .WithRoute("api/items")
            .WithPermission(ItemsPermissions.Read, ItemsPermissions.Module));

        app.MapPostMediatrRequest<CreateItemCommandInfo.Command, long>(cfg => cfg
            .WithRoute("api/items")
            .WithPermission(ItemsPermissions.Manage, ItemsPermissions.Module));

        // Параметр в пути + тело: перегрузка с TRequestParams, одноимённые свойства переносятся в команду
        app.MapPutMediatrCommandWithParams<UpdateItemCommandInfo.Params, UpdateItemCommandInfo.Command>(cfg => cfg
            .WithRoute("api/items/{id}"));

        app.MapDeleteMediatrRequest<DeleteItemCommandInfo.Command>(cfg => cfg
            .WithRoute("api/items/{id}"));

        app.MapPostMediatrFileRequest<ExportItemsCommandInfo.Params, ExportItemsCommandInfo.Command, FileDto>(cfg => cfg
            .WithRoute("api/items/export"));
    }
}

4.3 Какой маппинг брать

Метод HTTP Откуда биндится запрос Ограничения
MapGetMediatrRequest<TRequest, TResponse> GET query и маршрут ([AsParameters]) TRequest : IQueryBase<TResponse>, new()
MapPostMediatrRequest<TRequest> POST JSON-тело ICommandBase; плейсхолдер в маршруте — ошибка на старте
MapPostMediatrRequest<TRequest, TResponse> POST JSON-тело ICommandBase<TResponse>; плейсхолдер — ошибка на старте
MapPostMediatrRequest<TRequestParams, TRequest, TResponse> POST маршрут/query в TRequestParams + тело; одноимённые свойства копируются в команду
MapPostMediatrCommandWithParams<TRequestParams, TRequest> POST то же, команда без результата
MapPostMediatrParameterRequest<TRequest, TResponse> POST только параметры, без тела
MapPostMediatrFormRequest<TRequest, TResponse> POST multipart/form-data (файлы + поля)
MapPostMediatrFileRequest<TRequestParams, TRequest, TResponse> POST параметры + тело, ответ — файл TResponse : FileDto
MapPutMediatrRequest<TRequest, TResponse> PUT JSON-тело плейсхолдер — ошибка на старте
MapPutMediatrRequest<TRequestParams, TRequest, TResponse> PUT параметры + тело
MapPutMediatrCommandWithParams<TRequestParams, TRequest> PUT параметры + тело, без результата
MapDeleteMediatrRequest<TRequest> DELETE маршрут/query ICommandBase, new()

Правило: в маршруте есть {id} — перегрузка с TRequestParams. Двухдженериковые POST/PUT биндят только тело; раньше {id} молча оставался default, с 0.4.0 это ошибка на старте с подсказкой.

Класс параметров — обычный класс с [FromRoute]/[FromQuery]:

public sealed class Params { [FromRoute] public long Id { get; set; } }
public sealed class Command : CommandBase { public long Id { get; set; } public string Name { get; set; } = ""; }

Нестандартное (стрим файла, SSE) — обычный app.MapGet(...) рядом, с .RequireAuthorization() и .WithMetadata(new RequirePermissionAttribute(...)) руками, как RegisterDownload в WareHub.

4.4 Опции эндпоинта

cfg — MinimalApiEndpointOptions. Имя и тег выводятся из пространства имён команды, саммари — из XML.

Метод Действие
WithRoute("api/...") обязателен
WithName, WithTag, WithTagPrefix, WithSummary переопределить выведенное
IsAnonymous() без RequireAuthorization (по умолчанию все эндпоинты требуют аутентификацию)
IsHidden() не показывать в Swagger
WithPermission(code, module?, feature?) RequirePermissionAttribute в метаданные, проверяет §4.7

Каждый эндпоинт получает Produces для 400 (ValidationErrorResponse), 404/409/500 (ErrorResponse).

4.5 Ошибки

Единый контракт ответа с ошибкой — ErrorResponse { message, code, correlationId }:

  • BusinessLogicException(message) → 400; (HttpStatusCode, message, code) → любой статус; параметры маршрута/query/формы приводятся к типам команды инвариантно (числа с точкой), включая Nullable<T>, перечисления, Guid, DateOnly; (HttpStatusCode, body) — своё тело как есть, когда фронту нужны структурные данные.
  • Конфликты базы → 409: concurrency_conflict (запись изменил другой запрос) и unique_conflict (уникальность); подробности — только в логе (0.9.7).
  • Всё остальное → 500 internal_error; текст исключения наружу не уходит, correlationId — для логов.
  • Лог: ожидаемые 4xx (BusinessLogicException, ошибки биндинга/чтения запроса) — Information без стека в категории Ava3v.WebApi.Errors; необработанные исключения — Error со стеком. BadHttpRequestException со статусом 413/431 (Kestrel при чтении тела) отдаётся своим статусом и кодом request_rejected.
  • Включается app.ConfigureExceptionHandler() первым в конвейере.
  • X-Correlation-Id: CorrelationIdMiddleware берёт из запроса или генерирует, кладёт в ответ и в scope логгера.

4.6 Локализация

Localization:DefaultCulture (по умолчанию ru-RU) и SupportedCultures (["ru-RU","en-US"]). Культура запроса — по Accept-Language и стандартным провайдерам UseRequestLocalization. Строки либы — в SharedResources (en нейтральная, ru), ключи — константы LocalizationKeys (Common_*, Auth_*, Session_*, ApiKey_*, Audit_*, Permission_*, Validation_*). В хендлере: HandlerContext.Localizer[key]; безопасно — Localizer.GetStringOrDefault(key, fallback). Enumeration<T> локализуется ключом Enum_{Тип}_{Значение} через Localizer.LocalizeEnum(value). Свои строки продукт держит в своём resx и своём IStringLocalizer<T>, механизм тот же. Тексты 400 из грида (Components.Contract) локализованы отдельно по CurrentUICulture без DI.

4.7 Права

Библиотека проверяет, продукт хранит. Продукт реализует один интерфейс:

public sealed class AppPermissionService(AppDbContext db) : IAva3vPermissionService
{
    public Task<bool> HasPermissionAsync(long userId, string permissionCode, string? moduleCode, string? featureCode, CancellationToken ct = default)
        => db.UserPermissions.AnyAsync(x => x.UserId == userId && x.Code == permissionCode
                                            && (moduleCode == null || x.Module == moduleCode), ct);
}

services.AddAva3vPermissionAuthorization<AppPermissionService>() + app.UseAva3vPermissionAuthorization() после UseAuthorization(). На эндпоинте — cfg.WithPermission(...) (вызовы накапливаются) или RequirePermissionAttribute в метаданных — несколько прав, все должны пройти. Не аутентифицирован → 401 auth_required, нет права → 403 access_denied. Решения кешируются в пределах запроса. Эндпоинт с WithPermission без UseAva3vPermissionAuthorization() в конвейере отвечает 500 (отказ по умолчанию), а не выполняется без проверки; свой middleware прав отмечает запрос проверенным через HttpContext.Items[Ava3vPermissionAuthorizationMiddleware.CheckedItemKey].

Идентичность — пара (вид, id). Middleware зовёт HasPermissionAsync(Ava3vUserKey user, …); вид берётся из клейма user_kind сырым значением (ICurrentUserService.UserKindRaw, включая Service/Integration). Реализация интерфейса по умолчанию делегирует перегрузке по id только для сотрудников (вид Internal либо пустой) и отказывает остальным видам (0.9.7): сервис прав, написанный по одному userId, иначе выдавал API-ключу ("Service", 1) права сотрудника №1. Продукт, который выдаёт API-ключи сервисным аккаунтам или заводит другие виды пользователей, переопределяет перегрузку с ключом — без этого такие субъекты получают 403 на всех эндпоинтах с правами. RequireModuleAttribute/RequireFeatureAttribute библиотекой не проверяются (obsolete): скоуп модуля и фичи передаётся в RequirePermission/WithPermission. Описания прав для админки — IPermissionDefinitionProvider и записи ModuleDefinition/FeatureDefinition/PermissionDefinition.

4.8 Текущий пользователь

ICurrentUserService (регистрирует AddAva3vCoreWebApi): UserId, UserKind, ContragentId, IsAuthenticated, IsInternalUser/IsExternalUser/IsSystemUser. Вне HTTP (фоновые задачи, сидеры) — ICurrentUserContext.BeginScope(new CurrentUserContextState(userId, kind, contragentId)) или BeginSystemScope(): контекст базы возьмёт автора оттуда, иначе SystemConstants.SystemUserId.

5. Аутентификация: JWT

AddAva3vCoreWebApi включает AddAva3vJwtAuthentication (повторный вызов безопасен). Секция Jwt:

Jwt:Mode Кто выпускает Кто проверяет Нужно
Symmetric (по умолчанию) сам сервис (IAva3vTokenIssuer, HS256) сам сервис Key (≥ 32 байт), Issuer, Audience
Jwks внешний провайдер по JWKS провайдера External:Authority (или MetadataAddress), External:Audience
Both и то, и другое по iss токена: свой → Symmetric, чужой → схема External:SchemeName всё выше

Откуда берётся access-токен, по убыванию приоритета: заголовок Authorization: Bearer → httpOnly-кука AuthCookie:AccessTokenName → ?access_token= на путях Jwt:QueryTokenPaths (SignalR без кук). Заголовок и кука в одном запросе — считается заголовок. Кука и query читаются только собственной схемой (Symmetric); внешняя схема (Jwks, вторая схема режима Both) принимает токен только заголовком. Внешняя схема требует Jwt:External:Audience — без него старт падает (иначе токен другого клиента того же realm проходил бы проверку); осознанный отказ от проверки — Jwt:External:AllowAnyAudience: true. Издатель проверяется всегда: External:Issuer, иначе External:Authority, иначе — issuer из документа метаданных (0.9.8: с одним MetadataAddress проверка была выключена). Клеймы внешнего токена — запрет по умолчанию (0.10.0): остаются только клеймы личности (sub, name, preferred_username, email, given_name, family_name и служебные iss/aud/exp/…); роли, группы и прикладные клеймы принимаются, только если перечислены в Jwt:External:AllowedClaims — именами, как в токене (roles, groups, scp): переименование обработчиком JWT (roles → ClaimTypes.Role) учитывается. Идентификатор субъекта — только sub: nameid и клейм-URI NameIdentifier из токена удаляются, несколько разных sub — 401. Клеймы библиотеки — user_kind, sid, api_key_id, api_key_name, api_key_scope — отбрасываются всегда; вид субъекта задаёт Jwt:External:UserKind (например External). В режиме Both он обязателен (ошибка старта): числовой sub провайдера иначе неотличим от сотрудника с тем же id — в правах, авторстве и журнале аудита. External:TrustUserKindClaim: true — доверять user_kind провайдера, если он собственный каталог пользователей продукта и сам размечает виды.

Смена ключа подписи (0.10.0). Новый ключ — в Jwt:Key, прежний — в Jwt:PreviousKeys (массив, каждый ≥ 32 байт): подписывает только Key, при проверке токенов и CSRF-значений принимаются все. Через время жизни refresh-токена прежний ключ убрать. Без этого смена ключа разлогинивает всех.

IAva3vTokenIssuer.IssueAccessToken(claims, lifetime?) выпускает токен теми же параметрами, что и проверка; GenerateRefreshToken() — случайная строка. Для логина напрямую он не нужен — это делает сервис сессий (§6).

На старте проверяются Key (≥ 32 байт), Issuer и Audience для Symmetric/Both — без них приложение не поднимется с понятным текстом. Режим Jwks: права (§4.7) и авторство в базе требуют числового sub; провайдер с GUID в sub даст 401 на эндпоинтах с правами и SystemUserId в CreatedBy.

Настройки сверх обязательных: ClockSkewSeconds (60), AccessTokenLifetimeMinutes (15), ExpiredHeaderName (при 401 token_expired добавляется заголовок <имя>: true; фронты исторически смотрят IS-TOKEN-EXPIRED), External:RequireHttpsMetadata (выключать только в dev).

6. Сессии и refresh-токены

6.1 Что это даёт

Короткий access (15 минут) плюс одноразовый refresh с ротацией. Сессия — sid, один на всю цепочку refresh. В базе только SHA-256 отпечаток refresh. Отзыв сессии или всех сессий пользователя долговечен: отметка отзыва sid пишется в базу до отзыва токенов и сверяется после каждой ротации, поэтому «выйти везде» не переживает и refresh, который в этот момент обновлялся (0.9.5): такая ротация либо сама видит отметку, либо — когда отзыв шёл внутри ещё не закоммиченной транзакции продукта — её преемник отвергается при первом же предъявлении. Отметки пишутся прямым INSERT мимо трекера: несохранённые правки продукта на том же контексте отзыв не сохраняет. В список отозванных (кеш) sid уходит после фиксации той транзакции, в которой сделан отзыв; при откате (явном, через Dispose или к точке сохранения) — не уходит. Refresh и отзыв не оборачивайте в транзакцию продукта, откатываемую по исключению: реакция на кражу заканчивается исключением, откат отменил бы сам отзыв. Access-токены отозванной сессии гасит список sid в IDistributedCache — мгновенно, пока кеш доступен; что происходит при его сбое — §6.2. Пользователь — пара Ava3vUserKey(Kind, Id): сотрудник и клиент с одинаковым Id — разные пользователи, у каждого вида своя политика сроков.

Протокол refresh: токен одноразовый. Повтор в окне гонки (10 с; вкладки обновились одновременно) от клиента с тем же User-Agent, что у ротировавшего, получает пару под тем же sid — один раз на токен; следующий повтор того же клиента — 409 refresh_superseded: не выход, запрос повторяется (в куках уже новый токен); сессии не отзываются. Фронту лучше не допускать одновременных refresh вовсе — один на все вкладки (Web Locks / BroadcastChannel). Правила для фронта: ветвиться по code, а не по статусу (409 отдаёт и конфликт базы); после refresh_superseded повторять ограниченное число раз и только в пределах окна — тот же токен, предъявленный ПОСЛЕ окна, это refresh_reused с отзывом сессий (ответы потерялись, нового токена у клиента нет — отправляйте на вход). Остаточный риск окна: клиент, скопировавший User-Agent и успевший за 10 с, получает одну параллельную линию токенов под тем же sid; вкладки одного браузера с РАЗНЫМ User-Agent (эмуляция устройства в DevTools) друг для друга — чужие клиенты. Refresh внутри своей транзакции или TransactionScope — только ReadCommitted: при Serializable / RepeatableRead проигравшие вкладки получат ошибку сериализации вместо 409. Своё хранилище обязано реализовать FindSuccessorAsync и TryIssueGraceAsync: реализации по умолчанию — без сверки клиента и без предела льгот (на старте об этом пишется Warning). Выход, зафиксированный во время гонки, даёт invalid_refresh, а не тревогу. Повтор от другого клиента либо вне окна — кража или очень старая вкладка, реакция по политике: RevokeAllSessions (по умолчанию) или RevokeSession. Токен, отозванный выходом, админкой или RevokeAllSessionsAsync, — просто invalid_refresh: тревоги и каскадного отзыва свежих сессий нет (0.9.0). Истёкший токен — refresh_expired независимо от отзыва. Абсолютный предел сессии при ротации не сбрасывается.

6.2 Подключение

services.AddAva3vCoreWebApi(configuration);
services.AddAva3vRefreshTokenStore<AppDbContext>();     // + modelBuilder.ApplyAva3vRefreshTokens("auth") в OnModelCreating
services.AddAva3vSessions(configuration);
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>();
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>();   // по желанию
services.AddScoped<IAva3vSessionEvents, SecurityJournal>();            // по желанию
services.AddAva3vMaintenance(configuration);

Обязательный IAva3vClaimsProvider — только клеймы продукта; sid, NameIdentifier и user_kind либа ставит сама и перезапишет одноимённые:

internal sealed class AppClaimsProvider(AppDbContext db) : IAva3vClaimsProvider
{
    public async Task<IReadOnlyList<Claim>> GetClaimsAsync(Ava3vUserKey user, CancellationToken ct = default)
    {
        var account = await db.Users.AsNoTracking().Where(x => x.Id == user.Id)
            .Select(x => new { x.Login, x.ContragentId }).FirstOrDefaultAsync(ct);
        if (account is null) return [];
        var claims = new List<Claim> { new(ClaimTypes.Name, account.Login) };
        if (account.ContragentId is { } cid) claims.Add(new Claim("contragent_id", cid.ToString()));
        return claims;
    }
}

IAva3vUserStateProvider.GetAsync(user) → Ava3vUserState(Exists, IsActive, IsBlocked). Нужен при Jwt:Sessions:CheckUserStateOnEachRequest (с кешем UserStateCacheSeconds) и ApiKeys:CheckOwnerState. IAva3vSessionEvents — хуки OnRefreshReuseDetectedAsync и OnSessionRevokedAsync для журнала безопасности.

Несколько экземпляров API — обязательно AddStackExchangeRedisCache: без него список отозванных sid живёт в памяти процесса, и UseAva3vSessions() пишет об этом предупреждение на старте.

Сбой кеша (Redis недоступен). Источник истины об отзыве — база, кеш только ускоряет проверку access-токенов:

  • запрос с токеном на защищённый эндпоинт получает 503 session_check_unavailable с Retry-After — не 500 и не 401: фронт не должен ни разлогинивать, ни идти на refresh. Анонимные эндпоинты (вход, refresh, csrf) работают и со «старой» access-кукой в запросе. Jwt:Sessions:CheckFailureMode = Allow — принять токен без проверки: доступность ценой того, что отозванные сессии проходят, пока кеш лежит;
  • состояние пользователя (CheckUserStateOnEachRequest, ApiKeys:CheckOwnerState) при сбое кеша читается у IAva3vUserStateProvider напрямую — блокировка продолжает действовать;
  • выход и «выйти везде» не падают из-за кеша: отзыв записан в базе, refresh отозванной сессии невозможен, событие OnSessionRevokedAsync уходит. На запись в кеш у отзыва общий бюджет 3 секунды; после первого sid, не попавшего в кеш, остальные не пробуются. Недонесённое дошлёт фоновая сверка: раз в Jwt:Sessions:RevokedListSyncSeconds (30; 0 — выключить) в кеш дописываются отметки отзыва, которых экземпляр туда ещё не клал, а если из кеша пропал контрольный ключ (перезапуск Redis без персистентности, очистка) — все отметки не старше срока жизни access-токена. Она же доносит отзывы других экземпляров при кеше в памяти процесса. Окно, в котором access-токен отозванной сессии ещё принимается после такого сбоя, — период сверки плюс длительность прохода (одна запись в кеш на новую отметку). Задача обслуживания не удаляет отметки раньше срока жизни access-токена даже при RefreshTokenRetentionDays = 0.

6.3 Эндпоинты продукта

Логин библиотека не маппит: пароль, попытки, блокировки — дело продукта. Скелет:

public sealed class AuthController : CustomController
{
    public static void Register(IEndpointRouteBuilder app)
    {
        app.MapPostMediatrRequest<LoginCommandInfo.Command, AuthResponseDto>(cfg => cfg.WithRoute("api/auth/login").IsAnonymous());
        app.MapPostMediatrRequest<RefreshCommandInfo.Command, AuthResponseDto>(cfg => cfg.WithRoute("api/auth/refresh").IsAnonymous());
        app.MapPostMediatrRequest<LogoutCommandInfo.Command>(cfg => cfg.WithRoute("api/auth/logout").IsAnonymous());
        app.MapGetMediatrRequest<MeQueryInfo.Query, UserInfoDto>(cfg => cfg.WithRoute("api/auth/me"));
    }
}

// Логин: хендлер получает IAva3vSessionService sessions, IAva3vSessionResponder responder,
// IAva3vAuthCookieService cookies, IHttpContextAccessor
var request = httpContext.Request;
var transport = responder.DetectTransport(request);                 // Cookie для браузера, Body для программного клиента
var tokens = await sessions.StartSessionAsync(new Ava3vUserKey("Internal", user.Id),
    Ava3vSessionClient.FromRequest(request), cancellationToken: ct);
// Факты самой сессии, которых нет у IAva3vClaimsProvider (он знает только пользователя): рабочее пространство, выбранное
// при входе; признак MFA; «вошёл от имени». Сохраняются в записи refresh-токена (колонка extra_claims) и попадают в
// access-токен при каждой ротации; предел — 4000 символов JSON (кириллица не экранируется), больше — ArgumentException
// при входе; издатель и свойства клейма не сохраняются. Формат колонки — Ava3vSessionClaims (для импорта и своих хранилищ).
// Ключевые клеймы — только от библиотеки: NameIdentifier/sub/nameid, Sid/sid, user_kind и служебные iss, aud, exp, nbf, iat, jti
// отбрасываются и из extraClaims, и из ответа IAva3vClaimsProvider:
//   sessions.StartSessionAsync(user, client, extraClaims: [new Claim("workspace_id", "7")], ct);

// Вариант А (эндпоинт через MediatR, как выше): тело ответа пишет обёртка MediatR из возвращённого DTO, поэтому
// responder.WriteAsync НЕ зовём (иначе тело записалось бы дважды) — только куки:
var csrf = transport == Ava3vTokenTransport.Cookie ? cookies.SetSession(httpContext.Response, tokens) : null;
return new AuthResponseDto { Sid = tokens.Sid, CsrfToken = csrf, /* для Body-транспорта — сами токены */ };

// Вариант Б (обычный minimal-API эндпоинт без MediatR): ответ целиком пишет библиотека
await responder.WriteAsync(httpContext.Response, tokens, transport, ct);
// Cookie: httpOnly access (/) + refresh (AuthCookie:RefreshPath) + не-httpOnly ava3v_csrf; в теле — сроки, sid, csrfToken
// Body:   { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, sid }

// Refresh: значение из тела (программный клиент) или из куки (браузер, с проверкой CSRF)
var refresh = Ava3vSessionTokenReader.ReadRefresh(request, cookies, body?.RefreshToken);
var access = Ava3vSessionTokenReader.ReadAccess(request, cookies);   // для сверки sid; истёкший допустим
var pair = await sessions.RefreshAsync(refresh ?? string.Empty, access, Ava3vSessionClient.FromRequest(request), ct);
await responder.WriteAsync(httpContext.Response, pair, responder.DetectTransport(request), ct);

// Выход
await sessions.LogoutAsync(cookies.ReadRefreshToken(request), Ava3vSessionClient.FromRequest(request), ct);
cookies.Clear(httpContext.Response);

// «Текущий пользователь»: заодно отдать CSRF-значение (для фронта на другом хосте — в теле)
dto.CsrfToken = cookies.IssueCsrfToken(httpContext.Response);

// Блокировка, смена пароля, удаление — гасим всё
await sessions.RevokeAllSessionsAsync(user, "password_changed", ct);
// Админка «активные сессии»: sessions.ListSessionsAsync(user), sessions.RevokeSessionAsync(user, sid, reason)

Ava3vSessionException из сервиса — 401 с кодом (§14). Полный рабочий пример — LoginCommandInfo в WareHub.

6.4 Браузер: как это выглядит с фронта

  1. POST /api/auth/login → куки поставлены, в теле csrfToken. Фронт держит csrfToken в памяти.
  2. Любой запрос: кука access уходит сама. На POST/PUT/PATCH/DELETE фронт добавляет X-CSRF-Token: <csrfToken>.
  3. 401 token_expired (или заголовок IS-TOKEN-EXPIRED: true) → POST /api/auth/refresh с кукой и CSRF-заголовком → новая пара, csrfToken той же сессии прежний (не ротируется, чтобы вторая вкладка не сломалась). Фронт всё равно запоминает csrfToken из каждого auth-ответа (тело и заголовок X-CSRF-Token): после перезапуска браузера refresh идёт с анонимным значением и возвращает значение, привязанное к сессии.
  4. Перезагрузка страницы: csrfToken в памяти потерян. Тот же origin — прочитать куку ava3v_csrf; другой хост — GET /api/auth/csrf (анонимный, app.MapAva3vCsrf()) либо csrfToken из ответа /me. 403 csrf_failed на любом запросе (вход в соседней вкладке под новой сессией) — взять /csrf и повторить один раз.
  5. 401 session_revoked, user_blocked, user_inactive, refresh_* → на страницу входа, refresh не поможет.
  6. 503 session_check_unavailable (кеш отозванных сессий недоступен) → повторить запрос через Retry-After; не разлогинивать и не идти на refresh.
  7. Выход — POST /api/auth/logout с CSRF-заголовком.

Порядок проверок для запроса с кукой: access истёк → 401 token_expired; сессия отозвана → 401 session_revoked; токен без sid (выпущен до сессий) → 401 token_expired; всё в порядке, но нет CSRF на изменяющем методе → 403 csrf_failed.

6.5 CSRF: где проверяется и почему так

Куки браузер шлёт сам, поэтому запрос с кукой на изменяющий метод должен доказать, что его отправил фронт, а не чужая страница: double-submit — значение не-httpOnly куки ava3v_csrf повторяется в заголовке X-CSRF-Token. Значение подписано: v1.<sid>.<nonce>.<HMAC-SHA256> от секрета (AuthCookie:Csrf:Secret, по умолчанию производный от Jwt:Key; в режиме Jwks ключа нет — секрет обязателен, проверка на старте) и привязано к сессии. /csrf привязывает значение к sid access-куки только после проверки её подписи (срок не проверяется): самодельный JWT с чужим sid получает анонимное значение. В режиме Jwks собственного ключа нет — sid из куки не читается, и привязка к сессии фактически выключена (остаётся подписанный double-submit). Это защита в глубину — основной барьер от чужих страниц по-прежнему нестандартный заголовок, которому нужен CORS-preflight. Голый double-submit обходился подбросом куки с соседнего поддомена (same-site cookie tossing, SameSite от своих поддоменов не защищает); теперь чужое, анонимное или самодельное значение не проходит там, где сессия запроса известна: в аутентификации — sid из клейма токена, в refresh — sid записи refresh-токена (сверка до ротации, 403 не сжигает токен жертвы). Анонимное значение (/csrf без access-куки, после перезапуска браузера) допускается только на refresh, ответ которого выписывает значение сессии. Проверка выполняется там, где кука становится полномочием:

  • в аутентификации, для токена из куки, после того как сам токен принят (истёкшая кука даёт свой 401, а не 403);
  • в IAva3vAuthCookieService.ReadRefreshToken/ReadAccessToken — для refresh и выхода.

Отдельного middleware нет. Анонимные эндпоинты, которые куки не читают (вход), CSRF не касается: старая кука в браузере ничего не блокирует. Отказ уходит после UseCors — с CORS-заголовками. Свой эндпоинт, читающий куки напрямую, зовёт cookies.IsCsrfValid(Request). Authorization: Bearer и безопасные методы (GET/HEAD/OPTIONS) не проверяются. AuthCookie:SameSite=None без CSRF — исключение на старте. CSRF-значение живёт, пока живёт кука; /me и /csrf с живой кукой ничего не переставляют.

6.6 Фронт и API на разных хостах

warehub.ava3v.ru ↔ api-warehub.ava3v.ru без прокси:

  • services.AddAva3vCors(configuration) + app.UseAva3vCors() до UseAuthentication; Cors:AllowedOrigins со схемой, без пути; поддомены — https://*.ava3v.ru; origin из кода — AddAva3vCors(configuration, o => o.Add(…)). *, origin с путём или без схемы, шаблон на целую зону — ошибка старта; порт по умолчанию и регистр нормализуются, схема любая (capacitor://localhost). Политика с credentials, любыми методами и заголовками, exposed X-CSRF-Token и Jwt:ExpiredHeaderName. Нужны свои exposed-заголовки (Content-Disposition) — своя политика AddCors, как в WareHub.
  • Фронт: credentials: 'include'; CSRF-значение берёт из тела логина/refresh/me/csrf или заголовка ответа X-CSRF-Token, не из куки (чужой хост её не отдаст).
  • Хосты должны быть same-site (поддомены одного домена), иначе SameSite=Strict не пропустит куки. Dev с фронтом на localhost:5173 и API на localhost:8081 — same-site, работает.
  • Secure вне Development ставится всегда (0.10.0), в Development — по схеме запроса; явное AuthCookie:Secure важнее.

6.7 Программные клиенты

Логин с заголовком X-Token-Transport: body (или уже с Authorization: Bearer) → токены в JSON. Дальше Authorization: Bearer, refresh — значением в теле. Куки не ставятся, CSRF не проверяется. При куке сессии в запросе транспорт всегда Cookie, что бы ни стояло в заголовках: куку (httpOnly-полномочие) обменять на токены в теле нельзя, иначе скрипт на странице выгружал бы refresh. Для интеграций лучше API-ключи (§7): без ротации и сроков.

6.8 Политики, отзыв, переезд

"Jwt": { "Sessions": {
  "DefaultPolicy": { "IdleMinutes": 720, "AbsoluteMinutes": 43200, "RaceWindowSeconds": 10, "ReuseReaction": "RevokeAllSessions" },
  "Policies": { "External": { "IdleMinutes": 120, "AbsoluteMinutes": 480 },
                "Integration": { "ReuseReaction": "RevokeSession" } },
  "CheckUserStateOnEachRequest": false, "UserStateCacheSeconds": 60,
  "AllowTokensWithoutSid": false, "RevokedSessionSkewSeconds": 120 } }

Незаданное поле политики вида — из DefaultPolicy. IdleMinutes — простой (переносится вперёд при ротации), AbsoluteMinutes — предел сессии.

Переезд с прежней схемы: new Ava3vRefreshTokenImport(store).ImportAsync(rows) один раз при выкатке — живые refresh становятся записями с отпечатком и новым sid; старый access без sid даёт 401 token_expired, фронт делает refresh и получает нормальную пару. Долгоживущие интеграционные токены без sid при включённых сессиях отвергаются: переведите интеграции на API-ключи, на время переезда AllowTokensWithoutSid: true.

6.9 SignalR

Хаб с кукой работает как есть (negotiate — POST, фронт добавляет X-CSRF-Token через withUrl(..., { headers })). Клиенты без кук передают ?access_token= — разрешите путь: Jwt:QueryTokenPaths: ["/hubs"].

7. API-ключи для интеграций

services.AddAva3vApiKeyStore<AppDbContext>();     // + modelBuilder.ApplyAva3vApiKeys("auth")
services.AddAva3vApiKeys(configuration);          // после AddAva3vCoreWebApi; секция ApiKeys

// Выдача из админки: ключ показывается один раз
var issued = await apiKeys.CreateAsync(new Ava3vUserKey("Service", account.Id), "1c-sync",
    scopes: ["orders.read", "stock.*"], TimeSpan.FromDays(365), ct);   // всё, что может владелец — [Ava3vApiKeyScopes.All]
return issued.Key;                                 // ak_{prefix}_{secret}
// apiKeys.ListAsync(owner), RevokeAsync(owner, id, reason), RevokeAllForOwnerAsync(owner, reason)

Запрос интеграции: X-Api-Key: ak_…. Схема ставится селектором перед JWT: есть заголовок — ключ, нет — обычная аутентификация. Владелец — сервисная учётка (вид, id), клеймы — из того же IAva3vClaimsProvider плюс api_key_id/api_key_name/api_key_scope; блокировка владельца гасит ключ (ApiKeys:CheckOwnerState). Лимит живых ключей ApiKeys:MaxActivePerOwner (3): ротация — выдать новый, перевести клиента, отозвать старый. Лимит атомарен (проверка и вставка — в сериализуемой транзакции EF-хранилища): параллельные создания его не пробивают. Имя ключа — 1–128 символов, срок жизни — положительный, иначе 400 api_key_invalid_request. «Последнее использование» пишется не чаще раза в ApiKeys:TouchIntervalMinutes (5). Отказы: 401 api_key_invalid / api_key_revoked / api_key_expired; превышение лимита при выдаче — api_key_limit. События — IAva3vApiKeyEvents.

Области действия (0.10.0). Ключ действует только в пределах scopes, заданных при выдаче: область — имя права, маски orders.* / orders:*, всё — "*". У права три имени — код, модуль.код, модуль.фича.код (RequirePermission("view", "finance") — это view и finance.view): ограничить ключ модулем — finance.view либо finance.*; область view осознанно даёт view во всех модулях. Middleware прав сначала сверяет все требуемые права эндпоинта с областями ключа (мимо — 403 api_key_scope_denied), затем спрашивает сервис прав владельца: область не даёт того, чего у владельца нет.

  • Эндпоинт без RequirePermission для ключа с ограниченными областями закрыт (403 api_key_scope_denied): иначе ключ «только stock.read» ходил бы под личностью владельца в профиль и выдачу ключей. Открывают [AllowApiKey] (.WithMetadata(new AllowApiKeyAttribute())), анонимный эндпоинт либо область *. Проверку делает UseAva3vPermissionAuthorization(); без него эндпоинты, смапленные библиотекой, ключу с ограниченными областями отказывают (500 — ошибка конфигурации), а обычные MapGet продукта области не увидят вовсе.
  • Ключ не выдаёт ключ шире себя: CreateAsync из запроса, аутентифицированного ключом, — только в пределах его областей.
  • Список разрешённых IP и ограничение частоты — на шлюзе: в библиотеке их нет.

8. Обслуживание: очистка и retention

services.AddAva3vMaintenance(configuration);   // секция Maintenance

Хост раз в IntervalMinutes (60; первый запуск через InitialDelaySeconds) выполняет задачи IAva3vMaintenanceTask в своём scope: отозванные/истёкшие refresh старше RefreshTokenRetentionDays (30), API-ключи старше ApiKeyRetentionDays (90), журнал аудита по AuditOptions.RetentionDays/SnapshotRetentionDays (null — вечно). Задачи регистрируют AddAva3vSessions/AddAva3vApiKeys/AddAva3vAudit; своя — AddAva3vMaintenanceTask<T>(). Ручной запуск — Ava3vMaintenanceHostedService.RunOnceAsync(). Без хоста таблицы растут. Несколько экземпляров приложения: проход берёт блокировку IAva3vMaintenanceLock (Ava3vDatabaseMaintenanceLock<TContext> — сессионная блокировка базы: Postgres, SQL Server, MySQL; без таблиц и миграций), экземпляр без блокировки проход пропускает (RunOnceAsync вернёт пустой результат). Блокировку регистрируют хранилища сессий, API-ключей и аудит (TryAdd — своя реализация, зарегистрированная раньше, важнее); явно — AddAva3vMaintenanceLock<TContext>(). Сессионной блокировке нужна настоящая сессия сервера: PgBouncer в transaction pooling и Npgsql Multiplexing её не держат. Имя — Maintenance:LockName (по умолчанию имя приложения): реплики одного продукта исключают друг друга, разные продукты в одной базе — нет.

9. Доступ к данным: Ava3v.DataAccess

9.1 Контекст

public sealed class AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUserService currentUser)
    : Ava3vDbContextBase(options, currentUser), IAuditSuppressible
{
    public DbSet<Item> Items => Set<Item>();
    public bool SuppressAudit { get; set; }

    protected override void OnModelCreating(ModelBuilder b)
    {
        base.OnModelCreating(b);
        b.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
        b.ApplyAva3vRefreshTokens("auth");
        b.ApplyAva3vApiKeys("auth");
        b.ApplyAva3vAudit(new AuditOptions { Schema = "audit" });
    }
}

Что делает Ava3vDbContextBase при SaveChanges:

Маркер сущности Поведение
IHasCreated CreatedBy = текущий пользователь — всегда, значение из DTO игнорируется; CreatedAt — UtcNow, если не задано. При обновлении оба поля возвращаются к исходным: маппер, положивший DTO в сущность (request.Adapt(entity)), авторство не перепишет (0.9.7); явно — только PreserveAuthorship()
IHasUpdated UpdatedAt/UpdatedBy при изменении
IHasDeleted Remove() превращается в мягкое удаление: IsDeleted, DeletedAt, DeletedBy (штамп ставит и снимает сервер — и при IsDeleted = true/false свойством; значения из DTO игнорируются); query-фильтр прячет удалённые. Пишутся только изменённые колонки. Зависимые, которых Remove() пометил каскадом (owned-типы, join-строки many-to-many, дети с каскадным FK), остаются: владелец в базе есть; дети с собственным IHasDeleted мягко удаляются сами. Детям с необязательной связью (nullable FK, ClientSetNull) Remove() обнуляет внешний ключ — он возвращается: связь с мягко удалённым владельцем сохраняется. Это касается и детей, удалённых явным Remove(child) в том же сохранении: каскадную пометку от явной не отличить. Физически удалить детей — отдельным сохранением, ExecuteDelete либо переопределением ShouldKeepCascadedDependent. Повторное удаление не перезатирает автора и время
IHasValidityPeriod ValidTo = now при удалении; query-фильтр «действует сейчас», момент — свойство ValidityMomentUtc контекста (параметр запроса, а не now() транзакции)
IHasActive, IHasBlocked дефолты true/false в конфигурации
IHasVersioning аудит пишет снимки версий
INotAuditable, [AuditIgnore] не в журнал аудита

Автор без HTTP-контекста — ICurrentUserContext.BeginScope, иначе SystemConstants.SystemUserId. Сохранить авторство из данных (сидеры, импорт, синхронизация) — только явно: using (context.PreserveAuthorship()) { … await context.SaveChangesAsync(); } (CreatedBy = 0 всё равно заменяется). Удалённые и недействующие записи читаются через IgnoreQueryFilters(). Фильтры библиотеки именованные (EF Core 10): Ava3vQueryFilters.SoftDelete и Ava3vQueryFilters.Validity — отключаются по одному: query.IgnoreQueryFilters([Ava3vQueryFilters.SoftDelete]) вернёт удалённые, не снимая тенант-фильтр продукта. Свои фильтры продукт тоже задаёт именованными — builder.HasQueryFilter("Tenant", x => x.TenantId == tenant), порядок относительно base.Configure не важен, фильтры складываются по AND. Безымянный HasQueryFilter(x => …) на сущности с IHasDeleted/IHasValidityPeriod EF отвергает при построении модели (смешивать нельзя) — до 0.9.5 он молча заменял фильтр мягкого удаления, и удалённые строки возвращались во все запросы. Период действия на прошлую дату — using (context.AsOf(date)) { … }: глобальный фильтр берёт момент из скоупа. Контекст для фильтра объявляется в Ava3vDbContextBase.ConfigureConventions — переопределяя его, вызывайте base.ConfigureConventions(...); порядок base.OnModelCreating относительно ApplyConfiguration не важен (0.9.6). query.ActualAt(date) накладывается ПОВЕРХ фильтра «действует сейчас» и годится для будущей даты или вместе с IgnoreQueryFilters().

9.2 Сущность и конфигурация

public sealed class Item : EntityBase, IHasCreated, IHasUpdated, IHasDeleted, IHasVersioning
{
    public string Name { get; set; } = string.Empty;
    public long CreatedBy { get; set; } public DateTime CreatedAt { get; set; }
    public long? UpdatedBy { get; set; } public DateTime? UpdatedAt { get; set; }
    public bool IsDeleted { get; set; } public long? DeletedBy { get; set; } public DateTime? DeletedAt { get; set; }
}

internal sealed class ItemConfiguration : Ava3vEntityConfiguration<Item>
{
    protected override string GetSchemaName() => "core";        // по умолчанию core
    public override void Configure(EntityTypeBuilder<Item> b)
    {
        base.Configure(b);                                       // таблица item в snake_case, ключ, дефолты, query-фильтры
        b.Property(x => x.Name).IsRequired().HasMaxLength(200);
    }
}

Ключ сущности — long Id (EntityBase): это условие аудита, грида и сессий.

9.3 snake_case

Конвенция Ava3vSnakeCaseConvention включена в Ava3vDbContextBase: таблицы (через ToTableInSnakeCase в базовой конфигурации), колонки, ключи, внешние ключи, индексы. Явные HasColumnName/HasName/ToTable не перебиваются; owned-типы получают только колонки {навигация}_{свойство}, complex-свойства — так же (contact_phone, вложенные — contact_geo_lat); owned-тип в JSON-колонке (ToJson()) — имя колонки по навигации в snake_case, имена JSON-свойств не трогаются. TPT-наследники получают свою snake_case-таблицу, TPH — нет; в TPT-иерархии имя первичного ключа на каждую таблицу строит EF (PK_<таблица>): явное имя ключа EF применяет ко всем таблицам иерархии сразу. Системная колонка Postgres xmin (uint + IsRowVersion()) не переименовывается. Аббревиатура — одно слово (0.10.0): IPAddress → ip_address, UserID → user_id, HTMLParser → html_parser, OrderV2Id → order_v2_id. Полного совпадения с EFCore.NamingConventions нет: Is2FAEnabled → is2_fa_enabled (у той библиотеки is2fa_enabled). Отключить: protected override bool UseSnakeCaseNaming => false.

Переезд с EFCore.NamingConventions: та библиотека переименовывала и __EFMigrationsHistory (migration_id/product_version), наша — модельная и историю миграций не трогает. На существующей базе старт падает 42703: column "MigrationId" does not exist. Один раз руками, без новой миграции:

ALTER TABLE "__EFMigrationsHistory" RENAME COLUMN migration_id TO "MigrationId";
ALTER TABLE "__EFMigrationsHistory" RENAME COLUMN product_version TO "ProductVersion";
ALTER TABLE "__EFMigrationsHistory" RENAME CONSTRAINT pk___ef_migrations_history TO "PK___EFMigrationsHistory";

9.4 Таблицы библиотеки и миграции

ApplyAva3vRefreshTokens(schema) → auth.ava3v_refresh_token и auth.ava3v_revoked_session (отметки отзыва сессий, 0.9.5); ApplyAva3vApiKeys(schema) → auth.ava3v_api_key; ApplyAva3vAudit(options) → audit.audit_log, audit.entity_snapshot. Миграции генерирует и применяет продукт своим обычным dotnet ef migrations add — библиотека их не поставляет. Версии, меняющие схему таблиц библиотеки, перечисляют изменения в CHANGELOG («Миграция»): 0.10.0 — entity_snapshot.tenant_id/scope_id и индексы, ava3v_api_key.scopes (существующим строкам — значение по умолчанию '*').

9.5 Страховка мягкого удаления

options.AddInterceptors(new SoftDeleteGuardInterceptor(loggerFactory));                       // предупреждение в лог
options.AddInterceptors(new SoftDeleteGuardInterceptor(mode: SoftDeleteGuardMode.Throw));     // или исключение до выполнения

using (SoftDeleteGuardInterceptor.AllowPhysicalDelete())   // осознанная чистка: retention помеченных строк, сброс демо-данных
    await context.Docs.IgnoreQueryFilters().Where(x => x.IsDeleted && x.DeletedAt < cutoff).ExecuteDeleteAsync(ct);

ExecuteDelete и сырой DELETE идут мимо трекера: строка сущности с IHasDeleted исчезает физически, без штампа удаления и записи аудита. Перехватчик распознаёт цель DELETE и TRUNCATE по таблицам модели (формы Postgres, SQLite, SQL Server, MySQL; литералы, комментарии и подзапросы в скобках не считаются) — страховка от случайности, а не граница безопасности; Remove() + SaveChanges (это UPDATE) и таблицы без IHasDeleted его не касаются.

10. Аудит: Ava3v.Audit

Журнал изменений по полям (Added/Modified/Deleted, старое и новое значение) и JSON-снимки версий сущностей с IHasVersioning — в той же транзакции, что и сами изменения.

services.AddAva3vAudit<AppDbContext>(o => { o.Schema = "audit"; o.WriteSnapshots = true; o.RetentionDays = 730; });
services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(cs).UseAva3vAudit(sp));
// По желанию: подписи полей, резолв ссылок, форматтеры, скоуп тенанта, политика чтения, имена авторов
services.AddScoped<IAuditFieldDescriptorProvider, ItemAuditFields>();
services.AddScoped<IAuditReferenceResolver, UserReferenceResolver>();   // читать через IgnoreQueryFilters
services.AddScoped<IAuditAccessPolicy, EntityAuditAccessPolicy>();      // обязательна для MapAva3vAudit (см. ниже)
services.AddScoped<IAuditUserNameResolver, UserNameResolver>();
app.MapAva3vAudit();   // GET api/audit/getShort?entityType=Item&entityId=5&field=&skip=0&take=20 → PagedResponse<AuditShortDto>
// Программно: IAuditLogService.GetShortAsync(...), IEntitySnapshotService.GetAsOfAsync<Item>(id, momentUtc)

Доступ к истории закрыт по умолчанию (0.9.5). В журнале лежат старые и новые значения полей любой сущности, а entityType + последовательный entityId перебирает каждый, у кого есть токен (внешний пользователь, API-ключ). MapAva3vAudit() без зарегистрированной IAuditAccessPolicy падает на старте; сам запрос без политики отвечает 403. Политика наследует право от сущности: «видит документ — видит его историю». Осознанно открыть эндпоинт любому аутентифицированному (однотенантное приложение, история не секретнее данных) — AddAva3vAudit<T>(o => o.EndpointAccess = AuditEndpointAccess.AllowAnyAuthenticated). Скоуп чтения (IAuditScopeProvider.GetReadScope) тоже fail-closed: «без ограничений» — только явный AuditReadScope.Unrestricted; null и скоуп без TenantId и без ScopeIds (у пользователя нет тенанта) ничего не показывают. С TenantId видны записи этого тенанта: уровня тенанта (без скоупа) и перечисленных ScopeIds; ScopeIds без TenantId — только записи этих скоупов, записи уровня тенанта чужих тенантов не подмешиваются. Без зарегистрированного провайдера скоупа ограничений нет — это приложение без тенантов. Страница истории ограничена AuditOptions.MaxTake (500): take не задан или больше — берётся предел.

Что попадает в журнал. Свойства самой сущности и — с 0.9.7 — то, что EF хранит отдельно от них, на владельце:

  • complex-свойства — поле Contact.Phone;
  • owned-типы — поле Address.City; замена ссылки целиком даёт «было → стало»; элемент owned-коллекции — Phones[+] / Phones[-] (значения элемента JSON-ом) и Phones[ключ].Number при правке;
  • связи many-to-many — поле-навигация на каждой аудируемой стороне: Roles: null → 5 (роль выдана), Roles: 5 → null (снята); у только что созданного владельца связи тоже записаны.

Автор записи — пара: created_by и created_by_kind (Internal, External, Service…; в DTO — AuthorKind). Имена авторов резолвятся по паре: IAuditUserNameResolver.ResolveAsync(IReadOnlyCollection<Ava3vUserKey>); реализация по умолчанию отдаёт имена только сотрудникам, остальные подписываются «Вид #id». Ключ сущности: целочисленный — в entity_id (включая отрицательные id встроенных учёток), любой другой (Guid, строка, составной «a|b») — текстом в entity_key; история по нему — ?entityType=Token&entityKey=… и IAuditLogService.GetShortByKeyAsync, политика доступа — IAuditAccessPolicy.EnsureCanReadByKeyAsync (по умолчанию отказ). Имя типа в журнале — AuditOptions.EntityTypeName (по умолчанию короткое CLR-имя: две сущности Item из разных пространств имён делят историю — задайте своё правило, пока журнал пуст). Не журналируются: ExecuteUpdate/ExecuteDelete, сущности с INotAuditable, поля с [AuditIgnore], complex-коллекции. Иерархия (TPH/TPT) ведётся под именем корневого типа — история и снимки читаются и по базовому, и по производному типу. Отсоединённое обновление (context.Update(entity), Attach + IsModified) журналируется против значений из базы: один запрос на тип сущности в сохранении, по запросу на owned-часть, сущность с составным ключом или необязательным complex-полем; так же читается отслеживаемая запись, переданная в Update(). Где прочитать значения нельзя (источник без таблицы) — прежнее значение пишется как ‹unknown›. Снимок сущности иерархии несёт ключ __entityType: GetAsOfAsync<T> собирает именно записанный тип. Массивы, списки и byte[] пишутся значением (JSON / base64) и сравниваются компаратором EF. Снимок версии включает owned-данные (ссылка — объектом, коллекция — массивом). [AuditIgnore] ставится на поле, на complex- или owned-навигацию целиком либо на сам owned- или complex-класс. Снимки версий (IHasVersioning): complex-поля — вложенным объектом; сущность с нецелочисленным ключом читается IEntitySnapshotService.GetAsOfByKeyAsync<T>(key, moment).

Правила и ограничения:

  • AddDbContext, не AddDbContextPool: интерсептор scoped и хранит буферы одного сохранения. AddDbContextFactory — только со scoped-опциями (AddDbContextFactory<T>((sp, o) => o.UseAva3vAudit(sp), ServiceLifetime.Scoped)); singleton-опции отдают UseAva3vAudit корневой провайдер — отказ при создании контекста. Пул и один экземпляр перехватчика на двух одновременно сохраняющих контекстах — AuditWriteException на каждом сохранении. UseAva3vAudit — последним в AddInterceptors.
  • Заглушки: Attach(new T { Id = … }) + присваивание журналируется против значений из базы (признак заглушки — незаполненный CreatedAt, нужен IHasCreated). Сущность без IHasCreated обновляйте отслеживаемой либо Update(entity). Строки с действительно пустым created_at неотличимы от заглушек и стоят чтения из базы на каждой правке — заполните колонку либо AuditOptions.DetectAttachedStubs = false.
  • Начальные значения (AuditOptions.WriteInitialValues, выключено): строки Added с Field/NewValue на каждое заполненное поле созданной записи; объём журнала на вставках растёт кратно числу полей.
  • Удаление сущности с периодом действия (Remove() → ValidTo = now) пишется как Deleted со снимком; [AuditIgnore] действует и на снимки версий.
  • Журнал пишется прямым INSERT в той же транзакции, мимо трекера вызывающего (через командный конвейер EF: лог и перехватчики команд работают). Без явной транзакции интерсептор открывает свою (UseOwnTransaction). Откат, ошибка, отмена — журнала нет. SaveChanges(acceptAllChangesOnSuccess: false) поддерживается (дублей нет).
  • EnableRetryOnFailure: сохранения с аудитом — только внутри CreateExecutionStrategy().ExecuteAsync(...), иначе интерсептор бросает понятную ошибку. Чтобы повтор был безопасен при сбое записи журнала, сохраняйте с отложенным приёмом изменений: await strategy.ExecuteAsync(() => ctx.SaveChangesAsync(acceptAllChangesOnSuccess: false)); ctx.ChangeTracker.AcceptAllChanges(); Обычный SaveChangesAsync() при таком сбое бросает нетранзиентный AuditWriteException (EF уже принял изменения, транзакция откатана) и помечает контекст негодным — тихого «успеха» с пустой базой нет ни при какой стратегии. Сообщение исключения называет исход: своя транзакция откатана; транзакцию вызывающего откатывает он сам; без транзакции (UseOwnTransaction = false) данные уже зафиксированы и повторять операцию нельзя.
  • Ключ таблиц журнала может быть identity (значение выдаёт база) либо HiLo/клиентский генератор (значение берётся у генератора EF).
  • Правка + Remove() в одном сохранении журналируются обе (Modified и Deleted); восстановление (IsDeleted true → false) — действие Restored. Снимок строится по OriginalValues трекера: удаление stub-сущности (Attach + Remove без загрузки) даёт снимок с дефолтами — загружайте сущность перед удалением.
  • ExecuteUpdate/ExecuteDelete обходят метаданные, мягкое удаление и журнал. Держите в проекте архитектурный тест с разрешённым списком таких вызовов; физический DELETE по таблицам с IHasDeleted ловит SoftDeleteGuardInterceptor (§9).
  • Отключить на время (миграции данных, массовая загрузка): context.SuppressAudit = true (IAuditSuppressible).
  • Автор записи — текущий пользователь, не CreatedBy сущности; lifecycle-поля не диффятся; значения режутся до MaxValueLength (4000).
  • Снимок на момент: GetAsOfAsync<T>(id, moment) — версия с ValidFrom ≤ moment < ValidTo.

11. Серверный грид и дерево: Ava3v.Components.Contract

Серверная половина Ava3vDataGrid из @ava3v/ui-vue и ui-react. Фронт шлёт GridDataSourceLoadOptions, бэк применяет их к IQueryable<TRow> и отдаёт GridLoadResponse. Формат запроса с фронта описан в docs/datagrid-backend-contract.md репозитория VueComponents.

11.1 Реестр

public sealed class Command : CommandBase<GridLoadResponse>
{
    /// <summary>Параметры загрузки грида (тело getBackendRequest() с фронта)</summary>
    public GridDataSourceLoadOptions? GridLoadOptions { get; set; }
    /// <summary>Фильтры продукта поверх грида</summary>
    public long? WarehouseId { get; set; }
}

internal sealed class Handler(IHandlerContext hc, AppDbContext db, IDataSourceLoaderAdapter grid)
    : CommandHandlerBase<Command, GridLoadResponse, AppDbContext>(hc, db)
{
    public override async Task<GridLoadResponse> Handle(Command request, CancellationToken ct)
    {
        IQueryable<ItemRowDto> query = Context.Items
            .Where(x => request.WarehouseId == null || x.WarehouseId == request.WarehouseId)
            .Select(x => new ItemRowDto { Id = x.Id, Name = x.Name, Qty = x.Qty, CreatedAt = x.CreatedAt });

        var result = await grid.LoadGridAsync(query, request.GridLoadOptions ?? new(), defaultSort: "Id", ct);
        return result.ToResponse();
    }
}

Проецируйте в DTO строки реестра до передачи в лоадер — это требование безопасности, а не стиль. Имена полей приходят от клиента: если источник — сущность, запрос UniqueValues.Field = "Owner.PasswordHash" отдаст значения, а startswith сработает как оракул. Фильтры и сортировки фронта приходят по именам полей DTO, а массовая операция «всё по фильтру» должна применяться к тому же запросу (§11.4). Когда источник всё же сущность: чувствительные свойства помечаются [GridIgnore] (так же действует безусловный [JsonIgnore]; условный [JsonIgnore(Condition = WhenWritingNull)] и серверный defaultSort запретом не затронуты) — 400 grid_field_denied на любом уровне пути; либо сервер задаёт белый список options.AllowedFields = ["Name", "Owner.Name"] перед загрузкой (с клиента он не читается). С 0.10.0 это проверяется: тип элемента запроса — сущность модели EF (как бы она ни была получена: DbSet, Include, FromSql, навигация Select(l => l.Order), OfType) либо её незамапленный предок или интерфейс (IQueryable<IHasAuthor>, Cast), AllowedFields не задан — InvalidOperationException при загрузке. DTO с сущностью внутри: путь внутрь сущности (Doc.Author.Secret) без белого списка — 400 grid_field_denied. У дерева проверка идёт на входе и покрывает сортировку на всех ветках. Осознанно открыть все свойства и навигации — options.AllowAllFields = true (серверное, [JsonIgnore]). То же у TreeLoadOptions и GridSelectionRequest. defaultSort — стабильный порядок, когда фронт сортировку не прислал; для страниц обязателен. services.AddAva3vComponentsContract() регистрирует адаптер.

11.2 Что умеет лоадер

Размер запроса ограничен (GridDataLoaderSettings, задаются на старте): MaxTake 10 000, MaxFilterNodes 500 (узлы дерева фильтра и число фильтров колонок; глубина вложенности групп — 32), MaxFilterValues 10 000, MaxDateTokens 500, MaxSearchFields 64, MaxSummaryItems 64, MaxSortItems 16, MaxExpandedGroups 1000, MaxGroups 50 000 (листовых групп в ответе), MaxTreeNodes 50 000. Сверх предела — 400 grid_request_too_large (grid_group_too_many для групп). Условия сворачиваются в сбалансированное дерево выражений: пара тысяч условий в одном запросе раньше переполняла стек визиторов EF и роняла процесс. Сортировка и группировка — только по скалярным полям (коллекция, навигация, byte[] — 400 grid_filter_unsupported). Ключи раскрытых групп приводятся к типу поля уровня (enum именем или числом, DateOnly, numeric без хвостовых нулей).

Возможность Поле опций Как выполняется
Страница Skip, Take, RequireTotalCount OFFSET/FETCH и COUNT; GridDataLoaderSettings.MaxTake — предел (по умолчанию 10 000, экспорт им не ограничен); пользовательская сортировка дополняется defaultSort для стабильных страниц
Сортировка Sort[] { Field, Desc } по именам свойств, вложенные a.b
Фильтр колонки (хедер-фильтр) ColumnFilters[] { Field, Values, Mode: include/exclude, GroupInterval } IN/NOT IN; строки без регистра; даты с интервалом — полуинтервалы (ниже). Типы: строки, bool, все числа, Guid, перечисления (имя или число), DateTime, DateTimeOffset, DateOnly, TimeOnly и nullable. Значение не по типу — 400 grid_filter_value_invalid, поле другого типа — 400 grid_filter_unsupported
Дерево условий (конструктор фильтра) Filter { Type: condition/group, Field, Operation, Value, Operator, Items } строки: eq neq contains notcontains startswith endswith; остальные типы: eq neq gt gte lt lte between (регистр не важен; пустая операция — contains для строк и eq для остальных; порядковая операция с null — 400); Operator — and/or. Любая другая операция, оператор или режим — 400 (grid_filter_operation_unsupported, grid_filter_node_invalid); условие без Field/Value — 400, а не «нет фильтра». null в Value с eq/neq — IS NULL/IS NOT NULL
Поиск Search { Fields, Operation, Value } LIKE по полям; даты по компонентам; GridDataLoaderSettings.StringSearchMode = IndexOf для Oracle 12c
Уникальные значения (дерево хедер-фильтра) UniqueValuesField, UniqueValues { Field, GroupInterval, Skip, Take, Search } SELECT DISTINCT … LIMIT GridDataLoaderSettings.MaxUniqueValues (10 000) в SQL, сырые значения (для lookup — ключи, не тексты). Предел без порядка и без признака усечения; поиск по строковому полю выполняется в SQL ДО предела, по остальным типам — в памяти после него; Take ограничен MaxTake
Группировка Group[] { Selector, Desc }, ExpandedGroupKeys, RequireGroupCount GROUP BY в SQL до 4 уровней; свёрнутые группы одной строкой, раскрытые — окно данных (MaxTake действует). Глубже 4 уровней, нечисловые агрегаты групп, distinctCount по группам, больше 8 слотов агрегатов — 400 grid_group_unsupported; in-memory группировка (весь набор в память) включается только явно: для одного грида — options.AllowInMemoryGrouping = true (серверное), для процесса — GridDataLoaderSettings.AllowInMemoryGrouping. Так же options.MaxTake важнее общего GridDataLoaderSettings.MaxTake
Итоги GroupSummary[], TotalSummary[] { Selector, SummaryType: count/sum/min/max/avg/distinctCount, Name } в SQL. TotalSummary: distinctCount — COUNT(DISTINCT), min/max — и по датам/строкам; GroupSummary: count/sum/min/max/avg по числовым полям. Неизвестный тип или sum/avg по нечисловому полю — 400 grid_summary_unsupported
Экспорт options.ToFlatExport() те же фильтры, без страниц, группы → сортировка

Даты с GroupInterval (year/quarter/month/day): поля DateTime, DateTimeOffset, DateOnly и nullable. Уникальные значения — токены 2026, 2026-Q3, 2026-09, 2026-09-16; фильтр — [start, next) по UTC; null в значениях — «пусто». Усечение и границы транслируются в SQL (Postgres: timestamptz и date). Другой тип поля или неизвестный GroupInterval — 400 grid_date_interval_unsupported; любой токен не из списка форматов (мусор, 2026-1-3, год 0000/9999, несуществующая дата) — 400 grid_date_interval_invalid_token.

Ответ GridLoadResponse { data, totalCount, groupCount?, summary? }; при группировке data — смесь GridGroupHeaderEntry/GridDataEntry/GridGroupFooterEntry (kind: group/data/groupFooter). Типизированно строки достаются response.RowsOf<ItemRowDto>().

11.3 Простой список без грида

Для селектов и лукапов — SimpleDataSourceLoadOptions { Skip, Take, Search, Sort }: применяете сами (Where(x => x.Name.Contains(search)), Skip/Take), ответ тот же GridLoadResponse. В WareHub одна команда принимает и GridLoadOptions, и SimpleLoadOptions, и ветвится.

11.4 Массовые операции по выделению

Грид с включённым «выделить всё по фильтру» шлёт не ключи, а GridSelectionRequest: AllMatchingSelected, ExcludedKeys, Filter (тело getBackendRequest()), либо SelectedKeys.

public sealed class Command : CommandBase { public GridSelectionRequest Selection { get; set; } = new(); public long StatusId { get; set; } }

var rows = await ItemRegistryQuery.Project(Context, Context.Items)          // тот же запрос, что видит реестр
    .ResolveAsync(request.Selection, x => x.Id, maxAffectedRows: 10_000, ct);  // > лимита → 400 BulkLimitExceeded ДО изменений
var ids = await rows.Select(x => x.Id).ToArrayAsync(ct);
await foreach (var item in Context.Items.Where(x => ids.Contains(x.Id)).AsAsyncEnumerable().WithCancellation(ct))
{
    item.StatusId = request.StatusId;
}
await Context.SaveChangesAsync(ct);                                          // через SaveChanges: аудит и метаданные

ApplySelection — только запрос, EnsureWithinLimitAsync — только проверка (считает до лимита + 1, не весь набор). Лимит держите равным фронтовому selectAllMatchingLimit (10 000).

11.5 Дерево

TreeDataLoader пагинирует корни в SQL и раскрывает поддеревья по уровням; HasChildren считается одним SELECT DISTINCT parent_id … WHERE parent_id IN (…); поиск отдаёт только пути до совпадений. Перегрузка с одним источником (без (entities, projection)) при поиске или фильтре читает весь набор в память, чтобы поднять предков, — для больших деревьев используйте перегрузку с сущностями и проекцией (подъём индексный). Take корней ограничен MaxTake, число узлов ответа — MaxTreeNodes (400 grid_request_too_large) — и для страницы корней, и для батча детей (ParentIds, сам список — не длиннее MaxFilterValues); цикл в данных (узел — собственный предок) обход не вешает и процесс не роняет.

PagedResponse<IssueTypeDto> page = await TreeDataLoader.LoadAsync(
    Context.IssueTypes.AsNoTracking(),
    x => new IssueTypeDto { Id = x.Id, ParentId = x.ParentId, Name = x.Name },   // DTO : IHasId, IHasParentId, IHasChildrenInfo
    new TreeLoadOptions
    {
        Skip = request.Skip, Take = request.Take, ParentIds = request.ParentIds,
        Search = request.GridLoadOptions?.Search, ColumnFilters = request.GridLoadOptions?.ColumnFilters,
    },
    searchText: x => x.Name, ct);

TreeRegistryPageBuilder.BuildPage(...) — то же в памяти для небольших справочников. Обходы итеративные и с отметкой посещённых: цикл в ParentId (узел перенесли под собственного потомка, битый импорт) запрос не зацикливает, глубина дерева стек не ограничивает; узлы цикла, недостижимые от корня, в страницу не попадают. Запрет «перенести узел под своего потомка» остаётся валидацией продукта.

12. Мелочи: health, Mapster, корреляция

  • services.AddAva3vHealthChecks() + app.MapAva3vHealthChecks(): /health/live (процесс жив, без проверок) и /health/ready (проверки с тегом HealthCheckExtensions.ReadyTag) — для докера и балансировщика. Готовые проверки:

    services.AddAva3vHealthChecks()
        .AddAva3vDbContextCheck<AppDbContext>()   // CanConnectAsync в своём скоупе
        .AddAva3vDistributedCacheCheck()          // запись и чтение пробы: в кеше живут отозванные сессии
        .AddAva3vRevokedSyncCheck()               // до первой сверки отозванных сессий экземпляр не готов; старше 3 интервалов — не готов
        .AddAva3vMaintenanceCheck();              // нет прохода без сбоев за 3 интервала — Degraded (200, экземпляр в ротации)
    
  • Метрики и трассировка: Meter и ActivitySource с именем Ava3v (Ava3vMetrics) — builder.Services.AddOpenTelemetry().WithMetrics(m => m.AddMeter("Ava3v")).WithTracing(t => t.AddSource("Ava3v")). Счётчики: ava3v.sessions.refresh{outcome}, ava3v.sessions.started, ava3v.sessions.revoked, ava3v.apikeys.auth{outcome}, ava3v.maintenance.pass{outcome}, ava3v.maintenance.deleted{task}, ava3v.audit.rows, ava3v.audit.failures; гистограммы (мс): ava3v.maintenance.duration, ava3v.audit.write.duration, ava3v.grid.load.duration{mode,entity}; датчики возраста (с; −1 — ещё не было): ava3v.sessions.revoked_sync.age, ava3v.maintenance.age. Алерты: outcome=reused (кража токена), рост superseded по одной сессии, ava3v.audit.failures > 0, возраст сверки больше трёх интервалов.

  • TypeAdapterConfig.GlobalSettings.RegisterMapsterConfigurations(assemblies) — все IRegister сборок.

  • CorrelationIdMiddleware — §4.5.

13. Справочник appsettings

Секция Ключ По умолчанию Смысл
Localization DefaultCulture ru-RU должна входить в SupportedCultures
SupportedCultures ["ru-RU","en-US"]
SetProcessDefaultCulture true культура по умолчанию — потокам вне запроса на весь процесс (фоновые службы); false — не трогать процесс
Jwt Mode Symmetric Symmetric / Jwks / Both
Key — обязателен для Symmetric/Both, ≥ 32 байт, из секретов
PreviousKeys [] прежние ключи: принимаются при проверке, не подписывают (смена ключа без разлогина)
Jwt:External AllowedClaims [] прикладные клеймы внешнего токена, которые принимаются (роли, группы); остальное отбрасывается
Issuer, Audience — издатель и аудитория своих токенов
ClockSkewSeconds 60
AccessTokenLifetimeMinutes 15
ExpiredHeaderName — например IS-TOKEN-EXPIRED
QueryTokenPaths [] пути, где принимается ?access_token=
Jwt:External Authority, MetadataAddress, Audience, RequireHttpsMetadata (true), SchemeName (ExternalBearer), Issuer, UserKind, TrustUserKindClaim (false) для Jwks/Both; UserKind обязателен в Both
Jwt:Sessions DefaultPolicy.IdleMinutes 720 простой
DefaultPolicy.AbsoluteMinutes 43200 предел сессии
DefaultPolicy.RaceWindowSeconds 10 окно гонки вкладок: один льготный ответ на отозванный ротацией токен, клиенту с User-Agent ротировавшего; дальше тому же клиенту — 409 refresh_superseded (повторить запрос), чужому — refresh_reused
DefaultPolicy.ReuseReaction RevokeAllSessions или RevokeSession
Policies:{UserKind}.* из DefaultPolicy переопределение по виду
CheckUserStateOnEachRequest false нужен IAva3vUserStateProvider
UserStateCacheSeconds 60
AllowTokensWithoutSid false переходный режим (§6.8)
RevokedSessionSkewSeconds 120 запас хранения отозванного sid
AuthCookie AccessTokenName ava3v_access_token
RefreshTokenName ava3v_refresh_token
AccessPath / RefreshPath / / /api/auth refresh-кука уходит только на эти пути
Domain —
SameSite Strict None требует CSRF
Secure вне Development — true, в Development — по схеме запроса явное значение важнее
AuthCookie:Csrf Enabled true
CookieName / HeaderName ava3v_csrf / X-CSRF-Token
Secret из Jwt:Key секрет подписи значения (≥32 байт); один на все экземпляры; в режиме Jwks обязателен (проверяется на старте)
Jwt:External Audience — обязателен для Jwks/Both; AllowAnyAudience: true — осознанно не проверять
ApiKeys HeaderName X-Api-Key
TouchIntervalMinutes 5 как часто писать «последнее использование»
MaxActivePerOwner 3
CheckOwnerState true нужен IAva3vUserStateProvider
Maintenance IntervalMinutes 60
InitialDelaySeconds 60
RefreshTokenRetentionDays 30
ApiKeyRetentionDays 90
LockName имя приложения блокировка проходов между экземплярами
Cors AllowedOrigins [] схема://хост[:порт], без пути; https://*.домен — поддомены; * — ошибка старта

AuditOptions настраивается кодом в AddAva3vAudit: Schema (audit), WriteSnapshots (true), UseOwnTransaction (true), MaxValueLength (4000), EndpointRoute (api/audit/getShort), RetentionDays/SnapshotRetentionDays (null — вечно; <= 0 — ошибка старта).

14. Справочник кодов ошибок

Тело всегда ErrorResponse { message, code, correlationId } (валидация — ValidationErrorResponse с errors).

Код Статус Когда Что делать фронту
auth_required 401 нет токена; не аутентифицирован на эндпоинте с правом на вход
token_expired 401 access истёк; собственный токен без sid при сессиях; плюс заголовок Jwt:ExpiredHeaderName refresh и повтор
session_revoked 401 сессия отозвана на вход
session_check_unavailable 503 проверка отзыва сессии недоступна (сбой кеша) повторить позже, сессию не сбрасывать
user_blocked, user_inactive 401 состояние пользователя на вход
invalid_refresh, refresh_expired, refresh_reused, session_mismatch 401 refresh не принят на вход
refresh_superseded 409 токен уже заменён более новым в окне гонки (третья вкладка, повтор после обрыва) повторить refresh: в куках уже новый токен
csrf_failed 403 кука без CSRF-заголовка на изменяющем методе взять CSRF (/csrf, /me) и повторить
access_denied 403 нет права показать отказ
api_key_invalid, api_key_revoked, api_key_expired 401 ключ интеграции
api_key_scope_denied 403 право эндпоинта вне областей действия ключа выдать ключ с нужной областью
api_key_limit 409 лимит живых ключей при выдаче (атомарен) отозвать старый
api_key_invalid_request 400 имя ключа пустое или длиннее 128, срок жизни неположительный исправить ввод
concurrency_conflict 409 запись изменена или удалена другим запросом (DbUpdateConcurrencyException) перечитать и повторить
unique_conflict 409 нарушение уникальности в базе; имя индекса и значение наружу не уходят исправить ввод
BulkLimitExceeded 400 массовая операция шире лимита уточнить фильтр
grid_date_interval_unsupported, grid_date_interval_invalid_token 400 дата-фильтр грида
grid_filter_value_invalid, grid_filter_unsupported 400 значение фильтра не по типу поля; поле нефильтруемого типа
parameter_invalid 400 параметр маршрута/query/формы не приводится к типу команды (включая ошибки биндинга [AsParameters])
grid_field_unknown, grid_filter_node_invalid, grid_filter_operation_unsupported 400 неизвестное поле, узел, оператор группы, режим колонки или операция; условие без поля/значения
grid_request_too_large, grid_group_too_many 400 запрос грида или дерева превышает пределы GridDataLoaderSettings (§11.2) упростить фильтр
grid_field_denied 400 поле с [GridIgnore]/[JsonIgnore] или вне AllowedFields
unsupported_media_type 415 форм-эндпоинт получил не multipart/form-data/x-www-form-urlencoded
grid_group_unsupported, grid_summary_unsupported 400 форма группировки не транслируется в SQL (см. §11.2); неизвестный тип итога или агрегат по нечисловому полю
request_rejected 413/431/… Kestrel отклонил тело или заголовки при чтении из обработчика (статус сохраняется)
transport_conflict 400 refresh в теле вместе с кукой сессии
internal_error 500 необработанное исключение показать correlationId
(без кода) 400 FluentValidation подсветить errors

15. Грабли

  • AddDbContextPool и AddDbContextFactory с singleton-опциями несовместимы с аудитом (отказ при первом сохранении / создании контекста). AddDbContext либо фабрика с ServiceLifetime.Scoped.
  • X-Correlation-Id от клиента принимается только 1–64 символа [A-Za-z0-9._:-], иначе заменяется своим.
  • EnableRetryOnFailure: сохранения с аудитом — внутри ExecutionStrategy; ротация refresh это учитывает сама.
  • ExecuteUpdate/ExecuteDelete обходят метаданные, мягкое удаление и аудит.
  • Postgres и даты: timestamptz требует DateTime с Kind=Utc и DateTimeOffset со смещением 0 при записи. Сравнивать и хранить в UTC.
  • Без Redis на нескольких экземплярах: список отозванных sid у каждого свой; отзывы соседей доносит сверка с базой (RevokedListSyncSeconds, до 30 с задержки), refresh отозванной сессии невозможен сразу. Предупреждение в логе не игнорировать.
  • Сбой Redis — 503 session_check_unavailable на защищённых эндпоинтах, не 500 (§6.2); фронту — повторить, не разлогинивать.
  • HasQueryFilter продукта на сущности с мягким удалением или периодом действия — только именованный (§9.1).
  • Задачи обслуживания удаляют пачками по 5000 строк: retention, включённый на большом журнале, не упирается в таймаут команды.
  • Secure: вне Development ставится всегда; стенд по http без TLS с окружением не Development — куки не доедут, задайте AuthCookie:Secure=false явно и осознанно.
  • Контекст из пула (AddDbContextPool) с аудитом несовместим; без аудита — можно: состояние AsOf()/PreserveAuthorship() привязано к выдаче контекста и следующему арендатору не достаётся.
  • Разнесённые хосты: same-site обязателен; CSRF-значение фронт берёт из тела или заголовка, не из куки; UseCors до UseAuthentication.
  • Порядок регистрации: AddAva3vApiKeys после AddAva3vCoreWebApi; UseAva3vPermissionAuthorization после UseAuthorization; ConfigureExceptionHandler первым.
  • Плейсхолдер в маршруте у двухдженерикового POST/PUT — ошибка на старте; берите перегрузку с TRequestParams.
  • Контроллер без static Register — исключение на старте.
  • snake_case и __EFMigrationsHistory при переезде с EFCore.NamingConventions — SQL в §9.3.
  • Sqlite в тестах не транслирует DateTimeOffset и без ICU понижает регистр только ASCII; продукты на Postgres.
  • PreserveAuthorship только для сидеров и импорта; через API автором всегда становится вызвавший.
  • Безопасность, коротко: гриду — DTO, не сущность (§11.1), либо [GridIgnore]/AllowedFields; MaxTake и соседние пределы по умолчанию включены — null только для доверенных внутренних гридов; права — по паре (вид, id), перегрузку HasPermissionAsync(Ava3vUserKey, …) переопределяет каждый продукт с API-ключами; SystemConstants.DefaultSystemUserPassword публичен (NuGet) и помечен obsolete — пароль встроенной учётной записи берётся из конфигурации или секрета окружения; на нескольких экземплярах — общий Redis (IDistributedCache) и один Jwt:Key/Csrf:Secret на все.

16. Тесты

dotnet test Ava3vLibs.slnx -c Release

Восемь тест-проектов, 283 теста. Грид — на Sqlite в памяти (трансляция в SQL проверяется), сессии и JWT — TestServer с Sqlite, три проекта на настоящем Postgres 14 через Testcontainers (аудит в транзакциях, гонка refresh между экземплярами, интервалы дат для timestamptz/date). Без Docker локально Postgres-тесты помечаются Skipped; в CI (CI=true или AVA3V_REQUIRE_POSTGRES=1) их отсутствие роняет прогон; готовая база — TEST_POSTGRES_CONNECTION. Общая инфраструктура — tests/Ava3v.TestInfrastructure.

17. Релиз

Версия одна на все пять пакетов — <Version> в Directory.Build.props; версии зависимостей — в Directory.Packages.props. Это решение, не долг: потребители обновляют пять ссылок одной цифрой одним скриптом, матрицы совместимости между пакетами нет. Пересматриваем, когда появится внешний потребитель одного пакета, обновляющийся вразнобой. Цена — пустые бампы в CHANGELOG.

cd ~/Ava3vLibs
dotnet test Ava3vLibs.slnx -c Release
dotnet pack Ava3vLibs.slnx -c Release
dotnet nuget push "src/*/bin/Release/*.0.9.8.nupkg" \
  --source https://git.ava3v.ru/api/packages/Administrator/nuget/index.json \
  --api-key <gitea-токен с write:package> --skip-duplicate

Порядок пакетов важен только на пустом фиде (Abstractions первым). Одну и ту же версию Gitea второй раз не примет. После push: поднять версии в csproj потребителей, dotnet nuget locals http-cache --clear, собрать все бэки. Запись в CHANGELOG.md обязательна; ломающие изменения — с инструкцией миграции и готовым текстом для диалогов продуктов. Чек-лист и команды — docs/publishing.md.

Dependencies

ID Version Target Framework
Ava3v.Abstractions 0.10.0 net10.0
FluentValidation 12.1.1 net10.0
FluentValidation.DependencyInjectionExtensions 12.1.1 net10.0
Mapster 10.0.8 net10.0
MediatR 12.4.1 net10.0
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 net10.0
Microsoft.AspNetCore.OpenApi 10.0.9 net10.0
Microsoft.EntityFrameworkCore 10.0.9 net10.0
Microsoft.OpenApi 2.9.0 net10.0
Details
NuGet
2026-09-21 22:45:09 +03:00
1
ava3v
153 KiB
Assets (2)
Versions (67) View all
0.11.0 2026-10-22
0.10.3 2026-10-05
0.10.2 2026-10-05
0.10.1 2026-09-26
0.10.0 2026-09-21