Ava3v.Audit (0.9.0)
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.0 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 | CQRS-базы и маппинг minimal API → MediatR, ошибки, локализация, права, JWT, сессии, CSRF, CORS, API-ключи, обслуживание, health |
| Ava3v.Components.Contract | Abstractions, EF Core | Серверная сторона грида @ava3v/ui-vue: фильтры, сортировка, поиск, группировка, уникальные значения, массовые операции; загрузчик деревьев |
| Ava3v.Audit | WebApi.Core, DataAccess | Журнал изменений по полям и снимки версий в транзакции сохранения, 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.0" />
<PackageReference Include="Ava3v.DataAccess" Version="0.9.0" />
<PackageReference Include="Ava3v.WebApi.Core" Version="0.9.0" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.9.0" />
<PackageReference Include="Ava3v.Audit" Version="0.9.0" />
Страницы пакетов в 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.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>(); // по желанию: блокировка/активность
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
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— для логов. - Включается
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. Решения кешируются в пределах запроса. Описания прав для админки —
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 без кук).
Заголовок и кука в одном запросе — считается заголовок.
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. Отзыв сессии или всех сессий пользователя гасит и access-токены
мгновенно (список отозванных sid в IDistributedCache). Пользователь — пара Ava3vUserKey(Kind, Id):
сотрудник и клиент с одинаковым Id — разные пользователи, у каждого вида своя политика сроков.
Протокол refresh: токен одноразовый; повтор в окне гонки (вкладки обновились одновременно) — обе получают
пару под тем же sid; повтор ротированного токена вне окна — кража или очень старая вкладка, реакция по
политике: RevokeAllSessions (по умолчанию) или RevokeSession. Токен, отозванный выходом, админкой или
RevokeAllSessionsAsync, — просто invalid_refresh: тревоги и каскадного отзыва свежих сессий нет (0.9.0).
Абсолютный предел сессии при ротации не сбрасывается.
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() пишет об этом предупреждение на старте.
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);
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в памяти потерян. Тот же origin — прочитать кукуava3v_csrf; другой хост —GET /api/auth/csrf(анонимный,app.MapAva3vCsrf()) либоcsrfTokenиз ответа/me. - 401
session_revoked,user_blocked,user_inactive,refresh_*→ на страницу входа, 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. Проверка выполняется там, где кука становится полномочием:
- в аутентификации, для токена из куки, после того как сам токен принят (истёкшая кука даёт свой 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 не проверяется. Заголовок
действует только без кук сессии в запросе: куку (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-фильтр прячет удалённые |
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(); период действия на
дату — query.ActualAt(date).
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; 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>(); // без неё — любому аутентифицированному
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)
Правила и ограничения:
AddDbContext, неAddDbContextPoolи неAddDbContextFactory: интерсептор scoped и хранит буферы одного сохранения.- Удаление сущности с периодом действия (
Remove()→ValidTo = now) пишется какDeletedсо снимком;[AuditIgnore]действует и на снимки версий. - Журнал пишется вторым
SaveChangesтого же контекста в той же транзакции. Без явной транзакции интерсептор открывает свою (UseOwnTransaction). Откат, ошибка, отмена — журнала нет. EnableRetryOnFailure: сохранения с аудитом — только внутриCreateExecutionStrategy().ExecuteAsync(...), иначе интерсептор бросает понятную ошибку. ЛибоUseOwnTransaction = falseи своя транзакция.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 строки реестра до передачи в лоадер: фильтры и сортировки фронта приходят по именам
полей DTO, а массовая операция «всё по фильтру» должна применяться к тому же запросу (§11.4).
defaultSort — стабильный порядок, когда фронт сортировку не прислал; для страниц обязателен.
services.AddAva3vComponentsContract() регистрирует адаптер.
11.2 Что умеет лоадер
| Возможность | Поле опций | Как выполняется |
|---|---|---|
| Страница | Skip, Take, RequireTotalCount |
OFFSET/FETCH и COUNT; GridDataLoaderSettings.MaxTake — предел (по умолчанию нет); пользовательская сортировка дополняется 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 gt gte lt lte between contains startswith endswith; 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 — ключи, не тексты) |
| Группировка | Group[] { Selector, Desc }, ExpandedGroupKeys, RequireGroupCount |
GROUP BY в SQL до 4 уровней; свёрнутые группы одной строкой, раскрытые — окно данных |
| Итоги | GroupSummary[], TotalSummary[] { Selector, SummaryType: count/sum/min/max/avg/distinctCount, Name } |
в SQL |
| Экспорт | 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).
Другой тип поля — 400 grid_date_interval_unsupported, некалендарный токен — 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 считается одним
GROUP BY; поиск отдаёт только пути до совпадений.
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(...) — то же в памяти для небольших справочников.
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 |
||
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 | сессия отозвана | на вход |
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 |
401 | лимит живых ключей при выдаче | отозвать старый |
BulkLimitExceeded |
400 | массовая операция шире лимита | уточнить фильтр |
grid_date_interval_unsupported, grid_date_interval_invalid_token |
400 | дата-фильтр грида | |
grid_filter_value_invalid, grid_filter_unsupported |
400 | значение фильтра не по типу поля; поле нефильтруемого типа | |
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: отзыв сессий не общий между экземплярами. Предупреждение в логе не игнорировать.
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 автором всегда становится вызвавший.
16. Тесты
dotnet test Ava3vLibs.slnx -c Release
Восемь тест-проектов, около 210 тестов. Грид — на 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.0.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.