Ava3v.Audit (0.9.5)
Installation
dotnet nuget add source --name Administrator --username your_username --password your_token https://git.ava3v.ru/api/packages/Administrator/nuget/index.jsondotnet add package --source Administrator --version 0.9.5 Ava3v.AuditAbout this package
Единый аудит ava3v: журнал изменений по полям и снимки версий в транзакции самих изменений (SaveChanges-интерсептор), чтение с подписями полей, резолвом ссылок и форматированием значений, типовой запрос/эндпоинт истории сущности. Извлечено из Issues и 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 — только для тестов.
Содержание
- Из чего состоит
- Подключение
- Быстрый старт: пустое приложение
- CQRS и minimal API
- Аутентификация: JWT
- Сессии и refresh-токены
- API-ключи для интеграций
- Обслуживание: очистка и retention
- Доступ к данным: Ava3v.DataAccess
- Аудит: Ava3v.Audit
- Серверный грид и дерево: Ava3v.Components.Contract
- Мелочи: health, Mapster, корреляция
- Справочник appsettings
- Справочник кодов ошибок
- Грабли
- Тесты
- Релиз
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 Браузер: как это выглядит с фронта
POST /api/auth/login→ куки поставлены, в телеcsrfToken. Фронт держитcsrfTokenв памяти.- Любой запрос: кука access уходит сама. На POST/PUT/PATCH/DELETE фронт добавляет
X-CSRF-Token: <csrfToken>. - 401
token_expired(или заголовокIS-TOKEN-EXPIRED: true) →POST /api/auth/refreshс кукой и CSRF-заголовком → новая пара,csrfTokenтой же сессии прежний (не ротируется, чтобы вторая вкладка не сломалась). Фронт всё равно запоминаетcsrfTokenиз каждого auth-ответа (тело и заголовокX-CSRF-Token): после перезапуска браузера refresh идёт с анонимным значением и возвращает значение, привязанное к сессии. - Перезагрузка страницы:
csrfTokenв памяти потерян. Тот же origin — прочитать кукуava3v_csrf; другой хост —GET /api/auth/csrf(анонимный,app.MapAva3vCsrf()) либоcsrfTokenиз ответа/me. 403csrf_failedна любом запросе (вход в соседней вкладке под новой сессией) — взять/csrfи повторить один раз. - 401
session_revoked,user_blocked,user_inactive,refresh_*→ на страницу входа, refresh не поможет. - 503
session_check_unavailable(кеш отозванных сессий недоступен) → повторить запрос черезRetry-After; не разлогинивать и не идти на refresh. - Выход —
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, любыми методами и заголовками, exposedX-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); восстановление (IsDeletedtrue → 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.