Administrator
  • Joined on 2025-04-12

Ava3v.Components.Contract (0.9.5)

Published 2026-09-18 01:32:47 +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.9.5 Ava3v.Components.Contract

About this package

Серверные контракты компонентов ava3v: загрузчик данных грида (фильтры/сортировка/поиск/уникальные значения по IQueryable), построитель страницы древовидного реестра (рекурсивный поиск на всю глубину + пагинация по корням). Зависит только от Ava3v.Abstractions. Извлечено из 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.5" />
<PackageReference Include="Ava3v.DataAccess" Version="0.9.5" />
<PackageReference Include="Ava3v.WebApi.Core" Version="0.9.5" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.9.5" />
<PackageReference Include="Ava3v.Audit" Version="0.9.5" />

Страницы пакетов в 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) — своё тело как есть, когда фронту нужны структурные данные.
  • Всё остальное → 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 ради совместимости — продукт, который выдаёт API-ключи сервисным аккаунтам или заводит другие виды пользователей, обязан переопределить перегрузку с ключом, иначе сервисный аккаунт #1 проверяется как сотрудник #1. 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.

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 мимо трекера: несохранённые правки продукта на том же контексте отзыв не сохраняет. Access-токены отозванной сессии гасит список sid в IDistributedCache — мгновенно, пока кеш доступен; что происходит при его сбое — §6.2. Пользователь — пара Ava3vUserKey(Kind, Id): сотрудник и клиент с одинаковым Id — разные пользователи, у каждого вида своя политика сроков.

Протокол refresh: токен одноразовый; повтор в окне гонки (вкладки обновились одновременно) — обе получают пару под тем же sid; повтор ротированного токена вне окна — кража или очень старая вкладка, реакция по политике: 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 (для импорта и своих хранилищ):
//   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 со схемой. Политика с 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, работает.
  • AuthCookie:Secure=true в проде явно: за прокси по http автоопределение даст куки без 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": 60, "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", TimeSpan.FromDays(365), ct);
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; блокировка владельца гасит ключ (ApiKeys:CheckOwnerState). Лимит живых ключей ApiKeys:MaxActivePerOwner (3): ротация — выдать новый, перевести клиента, отозвать старый. «Последнее использование» пишется не чаще раза в ApiKeys:TouchIntervalMinutes (5). Отказы: 401 api_key_invalid / api_key_revoked / api_key_expired; превышение лимита при выдаче — api_key_limit. События — IAva3vApiKeyEvents.

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(). Без хоста таблицы растут.

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, если не задано
IHasUpdated UpdatedAt/UpdatedBy при изменении
IHasDeleted Remove() превращается в мягкое удаление: IsDeleted, DeletedAt, DeletedBy; query-фильтр прячет удалённые. Пишутся только изменённые колонки. Зависимые, которых Remove() пометил каскадом (owned-типы, join-строки many-to-many, дети с каскадным FK), остаются: владелец в базе есть; дети с собственным IHasDeleted мягко удаляются сами. Это касается и детей, удалённых явным 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)) { … }: глобальный фильтр берёт момент из скоупа. 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-типы получают только колонки {навигация}_{свойство}; TPT-наследники получают свою snake_case-таблицу, TPH — нет. Отключить: 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 — библиотека их не поставляет.

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 не задан или больше — берётся предел.

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

  • AddDbContext, не AddDbContextPool и не AddDbContextFactory: интерсептор scoped и хранит буферы одного сохранения.
  • Удаление сущности с периодом действия (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 обходят метаданные, мягкое удаление и журнал. Держите в проекте архитектурный тест с разрешённым списком таких вызовов.
  • Отключить на время (миграции данных, массовая загрузка): 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"] перед загрузкой (с клиента он не читается). 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 группировка (весь набор в память) включается только явно GridDataLoaderSettings.AllowInMemoryGrouping = true
Итоги 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); цикл в данных (узел — собственный предок) обход не вешает и процесс не роняет.

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, например базы) — для докера и балансировщика.
  • TypeAdapterConfig.GlobalSettings.RegisterMapsterConfigurations(assemblies) — все IRegister сборок.
  • CorrelationIdMiddleware — §4.5.

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

Секция Ключ По умолчанию Смысл
Localization DefaultCulture ru-RU должна входить в SupportedCultures
SupportedCultures ["ru-RU","en-US"]
Jwt Mode Symmetric Symmetric / Jwks / Both
Key — обязателен для Symmetric/Both, ≥ 32 байт, из секретов
Issuer, Audience — издатель и аудитория своих токенов
ClockSkewSeconds 60
AccessTokenLifetimeMinutes 15
ExpiredHeaderName — например IS-TOKEN-EXPIRED
QueryTokenPaths [] пути, где принимается ?access_token=
Jwt:External Authority, MetadataAddress, Audience, RequireHttpsMetadata (true), SchemeName (ExternalBearer) для Jwks/Both
Jwt:Sessions DefaultPolicy.IdleMinutes 720 простой
DefaultPolicy.AbsoluteMinutes 43200 предел сессии
DefaultPolicy.RaceWindowSeconds 60 окно гонки вкладок
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 по схеме запроса в проде за прокси — true явно
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
Cors AllowedOrigins [] со схемой, без завершающего /

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

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 не принят на вход
csrf_failed 403 кука без CSRF-заголовка на изменяющем методе взять CSRF (/csrf, /me) и повторить
access_denied 403 нет права показать отказ
api_key_invalid, api_key_revoked, api_key_expired 401 ключ интеграции
api_key_limit 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 несовместимы с аудитом. Только AddDbContext.
  • 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 за прокси: по http автоопределение даст куки без Secure; в проде AuthCookie:Secure=true явно.
  • Разнесённые хосты: 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.5.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.9.5 net10.0
Microsoft.EntityFrameworkCore 9.0.0 net10.0
Ava3v.Abstractions 0.9.5 net8.0
Microsoft.EntityFrameworkCore 7.0.0 net8.0
Details
NuGet
2026-09-18 01:32:47 +03:00
1
ava3v
168 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