Administrator
  • Joined on 2025-04-12

Ava3v.Audit (0.7.2)

Published 2026-09-16 14:15:30 +03:00 by Administrator

Installation

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

About this package

Единый аудит ava3v: журнал изменений по полям и снимки версий в транзакции самих изменений (SaveChanges-интерсептор), чтение с подписями полей, резолвом ссылок и форматированием значений, типовой запрос/эндпоинт истории сущности. Извлечено из Issues и WareHub.

Ava3vLibs

Монорепозиторий общих .NET-библиотек ava3v.

  • Общая версия пакетов — <Version> в Directory.Build.props (одна на всю монорепу).
  • Версии внешних зависимостей — в Directory.Packages.props (Central Package Management), меняются в одном месте.
  • Приватный NuGet-фид: https://git.ava3v.com/api/packages/Administrator/nuget.

Пакеты (зависимость строго вниз)

Пакет Зависит от Назначение
Ava3v.Abstractions — Базовые контракты (ноль зависимостей): интерфейсы сущностей (IHasId/IHasParentId/IHasChildrenInfo и др.), EntityBase, DTO, Enumeration/UserKindEnum, контексты, Security, PagedResponse, паджинг-запросы (IPagedQuery/IHasSearch/ITreePagedQuery), ошибки бизнес-логики.
Ava3v.Components.Contract Abstractions (+EFCore) Грид-лоадер (фильтры/сортировка/поиск/уникальные значения по IQueryable) + построитель древовидного реестра.
Ava3v.WebApi.Core Abstractions (+MediatR/AspNetCore/…) minimal-API → MediatR (CQRS): билдеры (GET/POST/PUT/DELETE/file/form-data), middleware, локализация, хендлер-базы, JWT.
Ava3v.DataAccess Abstractions (+EFCore.Relational) Базовый DbContext (аудит/soft-delete/период действия), snake_case-конфиг сущностей, query-фильтры.

Публикация

Токен Gitea (personal access token с правом write:package) держи у себя — в репо не коммитим. Перед публикацией новой версии подними <Version> в Directory.Build.props (одну и ту же версию Gitea второй раз не примет; --skip-duplicate это проглотит).

Всё сразу (обычный путь)

cd ~/Ava3vLibs
rm -rf artifacts
dotnet pack Ava3vLibs.slnx -c Release -o artifacts

dotnet nuget push "artifacts/*.nupkg" \
  --source https://git.ava3v.com/api/packages/Administrator/nuget \
  --api-key <твой-gitea-токен> \
  --skip-duplicate

По отдельности

Порядок важен, только если фид пустой: Ava3v.Abstractions публикуй первым — остальные от него зависят. dotnet pack любого проекта складывает .nupkg в общий artifacts/.

Общий шаблон:

cd ~/Ava3vLibs
PROJ=Ava3v.Abstractions   # или Ava3v.Components.Contract / Ava3v.WebApi.Core / Ava3v.DataAccess

dotnet pack src/$PROJ/$PROJ.csproj -c Release -o artifacts
dotnet nuget push "artifacts/$PROJ.*.nupkg" \
  --source https://git.ava3v.com/api/packages/Administrator/nuget \
  --api-key <твой-gitea-токен> \
  --skip-duplicate

Конкретно по каждому:

1. Ava3v.Abstractions (сначала)

dotnet pack src/Ava3v.Abstractions/Ava3v.Abstractions.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.Abstractions.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

2. Ava3v.Components.Contract

dotnet pack src/Ava3v.Components.Contract/Ava3v.Components.Contract.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.Components.Contract.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

3. Ava3v.WebApi.Core

dotnet pack src/Ava3v.WebApi.Core/Ava3v.WebApi.Core.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.WebApi.Core.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

4. Ava3v.DataAccess

dotnet pack src/Ava3v.DataAccess/Ava3v.DataAccess.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.DataAccess.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

Закоммитить исходники

git add -A
git commit -m "Ava3vLibs <version> — <что изменилось>"
git push

Как это потребляют другие проекты

В nuget.config потребителя добавить источник (один раз):

<packageSources>
  <add key="ava3v" value="https://git.ava3v.com/api/packages/Administrator/nuget/index.json" />
</packageSources>

Затем нужные пакеты (транзитивно тянут Ava3v.Abstractions):

<PackageReference Include="Ava3v.WebApi.Core" Version="0.2.0" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.2.0" />
<PackageReference Include="Ava3v.DataAccess" Version="0.2.0" />

Массовые операции по выделению грида (Ava3v.Components.Contract 0.2.33+)

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

// Команда: { GridSelectionRequest Selection; ... patch ... }
var rows = await db.Items.ResolveAsync(command.Selection, x => x.Id, maxAffectedRows: 10_000, ct);
// > лимита — BusinessLogicException с Code = "BulkLimitExceeded" ДО изменения данных (COUNT до лимита + 1)
await rows.ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, command.Status), ct);

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

Заметки к выпуску

Полный список — в CHANGELOG.md.

0.5.0 — refresh-токены и сессии (аддитивно)

  • Ava3v.Abstractions.Sessions: контракты IAva3vRefreshTokenStore, IAva3vClaimsProvider, IAva3vUserStateProvider, IAva3vSessionEvents, коды отказа Ava3vSessionErrorCodes (invalid_refresh, refresh_reused, session_mismatch, user_blocked, user_inactive, refresh_expired, session_revoked), Ava3vSessionException (401 + код).
  • Ava3v.DataAccess.Sessions: сущность Ava3vRefreshToken (в базе только SHA-256 отпечаток), ApplyAva3vRefreshTokens(schema), AddAva3vRefreshTokenStore<TContext>(); ротация атомарна (UPDATE … WHERE revoked_at IS NULL + вставка преемника в одной транзакции).
  • Ava3v.WebApi.Core.Web.Sessions: IAva3vSessionService (StartSession / Refresh / Logout / RevokeSession / RevokeAllSessions / ListSessions), IAva3vRevokedSessionList на IDistributedCache (с Redis — общий между экземплярами), IAva3vAuthCookieService (httpOnly, отдельный путь refresh), проверка отозванных sid и (по настройке) состояния пользователя в OnTokenValidated, AddAva3vSessions(configuration). Секции: Jwt:Sessions (сроки по виду пользователя, окно гонки, реакция на повтор), AuthCookie.
  • Протокол: refresh одноразовый; владелец сессии — запись токена; sid сохраняется при ротации; гонка вкладок (живой преемник в окне 60 с) — пара под тем же sid; повтор вне окна — отзыв всех сессий пользователя и гашение их access-токенов (настраивается), событие для аудита; абсолютный предел переносится при ротации.
  • Тесты: Sqlite (протокол, куки, состояние с кешем) и Postgres (гонка четырёх одновременных запросов с одним токеном, повтор между двумя экземплярами с общим списком отозванных).

0.4.0 — по замечаниям Cargo (ЛОМАЮЩЕЕ для колонок)

  • snake_case для колонок, ключей, внешних ключей и индексов — конвенция Ava3vSnakeCaseConvention включена в Ava3vDbContextBase по умолчанию (таблицы и раньше — ToTableInSnakeCase). Явные HasColumnName/HasName/ToTable не перебиваются. Потребителям на базовом контексте нужна новая миграция (колонки переименовываются); прежнее поведение — protected override bool UseSnakeCaseNaming => false.
  • AddAva3vJwtAuthentication идемпотентен: повторный вызов рядом с AddAva3vCoreWebApi (который уже включает JWT) больше не роняет старт «Scheme already exists: Bearer».
  • Ava3vJwtOptions регистрируется в DI (сам класс и IOptions<Ava3vJwtOptions>) — один источник истины для ключа/issuer/audience; новая настройка Jwt:AccessTokenLifetimeMinutes (15).
  • IAva3vTokenIssuer (режимы Symmetric/Both): IssueAccessToken(claims, lifetime?) и GenerateRefreshToken() теми же параметрами, что и проверка. Клеймы — за продуктом.
  • Двухдженериковые MapPostMediatrRequest/MapPutMediatrRequest (запрос из тела) с плейсхолдером в маршруте падают на старте понятной ошибкой вместо тихого default в параметре — берите перегрузку <TRequestParams, TRequest, TResponse>.

0.3.0 — ЛОМАЮЩЕЕ: авторство при добавлении

  • Ava3vDbContextBase: CreatedBy при добавлении ВСЕГДА берётся из текущего пользователя, заданное значение (в том числе прилетевшее из DTO через маппер) игнорируется. Раньше заполненный CreatedBy не трогался — клиент мог записать запись от чужого имени.
  • Сохранить исходного автора можно только из кода, явным режимом с областью действия: using (context.PreserveAuthorship()) { … await context.SaveChangesAsync(); } — для сидеров, генераторов демо-данных, переноса и синхронизации данных. В режиме CreatedBy = 0 всё равно заменяется текущим пользователем; UpdatedBy/DeletedBy режим не затрагивает; автор записи журнала аудита — всегда тот, кто выполняет операцию. CreatedAt это решение не трогает.
  • Что проверить у себя: все места, где CreatedBy задаётся явно (сидеры, импорты, DevData, генераторы, вызываемые через API — там автором станет вызвавший), обернуть в режим.
  • Аудит: тесты на настоящем Postgres (tests/Ava3v.Audit.PostgresTests, Testcontainers postgres:14.1 либо TEST_POSTGRES_CONNECTION); без Docker локально — Skipped, в CI (CI=true) — падение.

Сессии и refresh-токены (Ava3v.WebApi.Core + Ava3v.DataAccess, 0.5.0+)

// DI
services.AddAva3vCoreWebApi(configuration);                 // JWT + IAva3vTokenIssuer
services.AddAva3vRefreshTokenStore<AppDbContext>();         // таблица: modelBuilder.ApplyAva3vRefreshTokens("auth")
services.AddAva3vSessions(configuration);                   // сервис, куки, транспорт, список отозванных sid
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>();       // состав клеймов — продукт (sid, id, user_kind добавит либа)
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>(); // существует/активен/не заблокирован — по (вид, id)
services.AddScoped<IAva3vSessionEvents, SecurityJournal>();          // по желанию: reuse_detected, session_revoked
services.AddStackExchangeRedisCache(...);                            // на нескольких экземплярах; иначе память процесса
app.UseAuthentication(); app.UseAva3vSessions(); app.UseAuthorization();   // CSRF + предупреждения старта

// Логин (проверка пароля и попыток — в продукте); пользователь = (вид, id)
var user = new Ava3vUserKey("Internal", account.Id);
var tokens = await sessions.StartSessionAsync(user, Ava3vSessionClient.FromRequest(Request));
await responder.WriteAsync(Response, tokens, responder.DetectTransport(Request)); // Cookie или Body
// Обновление: refresh из тела (программный клиент) или из куки (браузер), access — заголовок или кука
var refresh = Ava3vSessionTokenReader.ReadRefresh(Request, cookies, body?.RefreshToken);
var pair = await sessions.RefreshAsync(refresh!, Ava3vSessionTokenReader.ReadAccess(Request, cookies), client);
await responder.WriteAsync(Response, pair, responder.DetectTransport(Request));
// Выход
await sessions.LogoutAsync(refresh, client); cookies.Clear(Response);
// Блокировка / удаление / смена пароля — только этот вид пользователя
await sessions.RevokeAllSessionsAsync(user, "password_changed");

Браузер и программные клиенты

  • Браузер: httpOnly-куки access (путь /) и refresh (путь AuthCookie:RefreshPath), рядом не-httpOnly ava3v_csrf. На POST/PUT/PATCH/DELETE фронт кладёт значение ava3v_csrf в заголовок X-CSRF-Token, иначе 403 csrf_failed. Обновление — тоже POST с кукой и CSRF-заголовком.
  • Программный клиент: логин → Transport=Body (заголовок X-Token-Transport: body либо запрос уже с Authorization: Bearer) → токены в JSON; дальше Authorization: Bearer, refresh — значение в теле запроса. CSRF не проверяется, куки не ставятся.
  • Кука и заголовок в одном запросе — считается заголовок.
  • Фронт и API на разных хостах (без прокси): CSRF-значение фронт берёт НЕ из куки (чужой хост её не отдаст), а из тела ответа логина/refresh (csrfToken) либо заголовка ответа X-CSRF-Token; при перезагрузке страницы — из ответа «текущий пользователь» (cookies.IssueCsrfToken(Response)). На API: services.AddAva3vCors(configuration) + app.UseAva3vCors() до UseAuthentication и Cors:AllowedOrigins: ["https://warehub.ava3v.ru"]; фронт шлёт запросы с credentials: 'include'. Хосты должны быть same-site (поддомены одного домена), иначе SameSite=Strict не пропустит куки.
  • SignalR: Jwt:QueryTokenPaths: ["/hub"] — ?access_token= принимается только на этих путях.
  • Коды 401: auth_required (нет токена), token_expired (обновись; плюс заголовок Jwt:ExpiredHeaderName), session_revoked, user_blocked, user_inactive. Refresh: invalid_refresh, refresh_reused, session_mismatch, refresh_expired.
  • Режим Both: свой токен в заголовке проходит по iss, внешний — на внешнюю схему. Настройки:
"Jwt": { "AccessTokenLifetimeMinutes": 15,
  "ExpiredHeaderName": "IS-TOKEN-EXPIRED", "QueryTokenPaths": ["/hub"],
  "Sessions": { "DefaultPolicy": { "IdleMinutes": 720, "AbsoluteMinutes": 43200, "RaceWindowSeconds": 60, "ReuseReaction": "RevokeAllSessions" },
                "Policies": { "External": { "IdleMinutes": 120, "AbsoluteMinutes": 480 },
                              "Integration": { "ReuseReaction": "RevokeSession" } },
                "CheckUserStateOnEachRequest": false, "UserStateCacheSeconds": 60 } },
"AuthCookie": { "AccessTokenName": "ava3v_access_token", "RefreshTokenName": "ava3v_refresh_token",
                "RefreshPath": "/api/auth", "SameSite": "Strict", "Secure": true,
                "Csrf": { "Enabled": true, "CookieName": "ava3v_csrf", "HeaderName": "X-CSRF-Token" } }

Переход с хранения токена как есть на отпечатки — однократный разлогин всех пользователей (старые записи не подходят под новую таблицу), переходного режима нет.

API-ключи для интеграций (0.7.0+)

services.AddAva3vApiKeyStore<AppDbContext>();      // таблица: modelBuilder.ApplyAva3vApiKeys("auth")
services.AddAva3vApiKeys(configuration);          // ПОСЛЕ AddAva3vCoreWebApi: схема X-Api-Key + селектор
// Выдача (ключ показывается один раз), отзыв, список — из админки продукта
var issued = await apiKeys.CreateAsync(new Ava3vUserKey("Service", account.Id), "1c-sync", TimeSpan.FromDays(365));
return issued.Key; // ak_{prefix}_{secret}

Запрос интеграции: X-Api-Key: ak_… — без сессий, refresh и CSRF. Владелец — сервисная учётка (вид, id); блокировка владельца гасит ключ. Лимит живых ключей ApiKeys:MaxActivePerOwner (3): ротация — выдать новый, перевести клиента, отозвать старый.

Переезд без разлогина (по желанию, 0.7.0+)

new Ava3vRefreshTokenImport(store).ImportAsync(rows) один раз при выкатке: прежние refresh становятся записями с отпечатком и новым sid. Старые access-токены без sid/user_kind 0.6.0 не принимает — клиент сделает refresh и получит нормальную пару. Без импорта — однократный разлогин.

Аудит (Ava3v.Audit, 0.3.0+)

Журнал изменений по полям и снимки версий в транзакции самих изменений. Контракты — в Ava3v.Abstractions.Audit, реализация — в пакете Ava3v.Audit. Подключение:

// DI
services.AddAva3vAudit<AppDbContext>(o => { o.Schema = "audit"; o.WriteSnapshots = true; });
services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(cs).UseAva3vAudit(sp));
// Своё: описания полей, резолверы ссылок, форматтеры, скоуп тенанта, политика доступа, имена авторов
services.AddScoped<IAuditFieldDescriptorProvider, IssueAuditFields>();
services.AddScoped<IAuditReferenceResolver, UserReferenceResolver>();   // читать через IgnoreQueryFilters!
services.AddScoped<IAuditScopeProvider, WorkspaceAuditScope>();         // TenantId/ScopeId + фильтр чтения
services.AddScoped<IAuditAccessPolicy, EntityAuditAccessPolicy>();      // без неё — любому аутентифицированному
services.AddScoped<IAuditUserNameResolver, UserNameResolver>();

// Контекст: наследник Ava3vDbContextBase (штамп метаданных и мягкое удаление — до аудита)
protected override void OnModelCreating(ModelBuilder b) { base.OnModelCreating(b); b.ApplyAva3vAudit(auditOptions); }
public bool SuppressAudit { get; set; }   // IAuditSuppressible — миграции и массовая загрузка

// Эндпоинт истории: GET api/audit/getShort?entityType=Issue&entityId=5&skip=0&take=20
app.MapAva3vAudit();
// Программно: IAuditLogService.GetShortAsync(...), IEntitySnapshotService.GetAsOfAsync<T>(id, moment)

Правила:

  • журнал пишется вторым SaveChanges того же контекста в той же транзакции; без явной транзакции интерсептор открывает свою и коммитит после журнала; откат/ошибка/отмена журнал не оставляют;
  • EnableRetryOnFailure несовместим с собственной транзакцией: оборачивайте сохранения в CreateExecutionStrategy().ExecuteAsync(...) (интерсептор бросает понятную ошибку, если этого не сделать), либо UseOwnTransaction = false;
  • ExecuteUpdate/ExecuteDelete обходят и метаданные, и мягкое удаление, и журнал — в проекте нужен архитектурный тест с разрешённым списком таких вызовов;
  • мягкое удаление пишется как Deleted; lifecycle-поля, [AuditIgnore], INotAuditable не пишутся;
  • автор записи — текущий пользователь (не CreatedBy сущности); ключ сущности — long;
  • таблицы {schema}.audit_log и {schema}.entity_snapshot — миграция на стороне потребителя.

Dependencies

ID Version Target Framework
Ava3v.Abstractions 0.7.2 net10.0
Ava3v.DataAccess 0.7.2 net10.0
Ava3v.WebApi.Core 0.7.2 net10.0
MediatR 12.4.1 net10.0
Microsoft.EntityFrameworkCore 10.0.9 net10.0
Microsoft.EntityFrameworkCore.Relational 10.0.9 net10.0
Details
NuGet
2026-09-16 14:15:30 +03:00
5
ava3v
45 KiB
Assets (2)
Versions (32) View all
0.11.0 2026-10-22
0.10.3 2026-10-05
0.10.2 2026-10-05
0.10.1 2026-09-26
0.10.0 2026-09-21