feat(audit): per-tenant журнал мутаций OrgAuditLog (P1-18)
Domain OrgAuditLog (TenantEntity) - per-org журнал create/update/delete
для Supply/SupplierReturn/RetailSale/Demand/Product/ProductPrice/
ProductBarcode/Counterparty (белый список в IsTracked).
Реализация: OrgAuditInterceptor (SaveChangesInterceptor) снимает diff на
SavingChanges (до commit), пишет в тот же DbContext в той же транзакции -
атомарно с самой мутацией. ChangesJson формата
{ "field": { "before": X, "after": Y } } - служебные поля
(OrganizationId/CreatedAt/UpdatedAt) пропускаются.
ITenantContext получил UserId (sub claim) для атрибуции событий.
AppDbContext.SkipAudit - escape-hatch для сидеров/системных операций.
Tenant-isolation: query-filter обычный TenantEntity-фильтр. B не видит
audit-строки A; SuperAdmin без override видит всё.
Контроллер GET /api/admin/audit-log с фильтрами entityType / entityId /
userId / action / from / to. Permission OrgSettingsManage.
Web: /audit-log для Admin'а - таблица с раскрывающимся JSON diff'ом,
цветные плашки create/update/delete, фильтры по типу и действию.
Миграция Phase8b_OrgAuditLog: jsonb-колонка, индексы
(OrgId+CreatedAt), (OrgId+EntityType+EntityId), (OrgId+UserId+CreatedAt).
Тесты: 3 интеграционных (create Product создаёт audit-запись;
update Counterparty - diff содержит before/after; tenant-изоляция:
B не видит записи A).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6b8ec5408a
commit
a189a5dd6e
|
|
@ -0,0 +1,61 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
/// <summary>Чтение per-tenant журнала мутаций. Запись — автоматическая через
|
||||||
|
/// <c>OrgAuditInterceptor</c>; этот контроллер только показывает. Доступ —
|
||||||
|
/// admin'у организации (через permission OrgSettingsManage). SuperAdmin
|
||||||
|
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/admin/audit-log")]
|
||||||
|
public class OrgAuditLogController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public OrgAuditLogController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record AuditRow(
|
||||||
|
Guid Id, DateTime CreatedAt,
|
||||||
|
Guid? UserId, string? UserName,
|
||||||
|
string Action, string EntityType, Guid? EntityId,
|
||||||
|
string ChangesJson);
|
||||||
|
|
||||||
|
[HttpGet, RequiresPermission("OrgSettingsManage")]
|
||||||
|
public async Task<ActionResult<PagedResult<AuditRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] string? entityType,
|
||||||
|
[FromQuery] Guid? entityId,
|
||||||
|
[FromQuery] Guid? userId,
|
||||||
|
[FromQuery] string? action,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.OrgAuditLogs.AsNoTracking().AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType);
|
||||||
|
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
|
||||||
|
if (userId is not null) q = q.Where(l => l.UserId == userId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(action)) q = q.Where(l => l.Action == action);
|
||||||
|
if (from is not null) q = q.Where(l => l.CreatedAt >= from);
|
||||||
|
if (to is not null) q = q.Where(l => l.CreatedAt <= to);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await (from l in q.OrderByDescending(l => l.CreatedAt)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
join u in _db.Users.AsNoTracking() on l.UserId equals u.Id into uj
|
||||||
|
from u in uj.DefaultIfEmpty()
|
||||||
|
select new AuditRow(
|
||||||
|
l.Id, l.CreatedAt,
|
||||||
|
l.UserId, u != null ? u.FullName : null,
|
||||||
|
l.Action, l.EntityType, l.EntityId,
|
||||||
|
l.ChangesJson))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<AuditRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,16 @@ public bool IsSuperAdmin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Guid? UserId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var sub = _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||||
|
?? _accessor.HttpContext?.User?.FindFirst("sub")?.Value;
|
||||||
|
return Guid.TryParse(sub, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsTenantOverride
|
public bool IsTenantOverride
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@
|
||||||
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
|
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
|
||||||
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
|
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
|
||||||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
|
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
|
||||||
|
// OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
|
||||||
|
// через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
|
||||||
|
builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
|
||||||
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
|
builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
|
||||||
{
|
{
|
||||||
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
|
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
|
||||||
|
|
@ -53,6 +56,8 @@
|
||||||
opts.UseOpenIddict();
|
opts.UseOpenIddict();
|
||||||
opts.AddInterceptors(sp.GetRequiredService<
|
opts.AddInterceptors(sp.GetRequiredService<
|
||||||
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
|
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
|
||||||
|
opts.AddInterceptors(sp.GetRequiredService<
|
||||||
|
foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>());
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddIdentity<User, Role>(opts =>
|
builder.Services.AddIdentity<User, Role>(opts =>
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,9 @@ public interface ITenantContext
|
||||||
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
|
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
|
||||||
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
|
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
|
||||||
bool IsTenantOverride { get; }
|
bool IsTenantOverride { get; }
|
||||||
|
|
||||||
|
/// <summary>Id текущего пользователя (sub claim в JWT). null для системных
|
||||||
|
/// операций без аутентификации (seed, фоновые джобы). Используется
|
||||||
|
/// для аудит-лога — кто инициировал мутацию.</summary>
|
||||||
|
Guid? UserId { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/food-market.domain/Organizations/OrgAuditLog.cs
Normal file
24
src/food-market.domain/Organizations/OrgAuditLog.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Organizations;
|
||||||
|
|
||||||
|
/// <summary>Журнал действий внутри организации: создание/правка/удаление
|
||||||
|
/// доменных сущностей (Supply, RetailSale, Demand, Product, Counterparty).
|
||||||
|
/// Tenant-scoped — каждая орга видит ТОЛЬКО свой журнал; SuperAdmin видит
|
||||||
|
/// всё (как и для прочих TenantEntity).
|
||||||
|
///
|
||||||
|
/// Пишется автоматически через <c>OrgAuditInterceptor</c> на SaveChanges:
|
||||||
|
/// для отслеживаемых типов снимается diff (old/new значения свойств),
|
||||||
|
/// сериализуется в JSON и сохраняется одной строкой.</summary>
|
||||||
|
public class OrgAuditLog : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
/// <summary>"create" | "update" | "delete".</summary>
|
||||||
|
public string Action { get; set; } = "";
|
||||||
|
/// <summary>Имя CLR-типа без неймспейса: "RetailSale", "Product"…</summary>
|
||||||
|
public string EntityType { get; set; } = "";
|
||||||
|
public Guid? EntityId { get; set; }
|
||||||
|
/// <summary>JSON-объект формата { "field": { "before": ..., "after": ... }, ... }.
|
||||||
|
/// Для create — поля только в "after"; для delete — только "before".</summary>
|
||||||
|
public string ChangesJson { get; set; } = "{}";
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,12 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public DbSet<Employee> Employees => Set<Employee>();
|
public DbSet<Employee> Employees => Set<Employee>();
|
||||||
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
||||||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||||||
|
public DbSet<OrgAuditLog> OrgAuditLogs => Set<OrgAuditLog>();
|
||||||
|
|
||||||
|
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
|
||||||
|
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
|
||||||
|
/// импортами (где tenant-context отсутствует) и при ручной чистке логов.</summary>
|
||||||
|
public bool SkipAudit { get; set; }
|
||||||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||||
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
|
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
|
||||||
|
|
||||||
|
|
@ -130,6 +136,17 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
|
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<OrgAuditLog>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("org_audit_log");
|
||||||
|
b.Property(x => x.Action).HasMaxLength(20).IsRequired();
|
||||||
|
b.Property(x => x.EntityType).HasMaxLength(100).IsRequired();
|
||||||
|
b.Property(x => x.ChangesJson).HasColumnType("jsonb").IsRequired();
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.EntityType, x.EntityId });
|
||||||
|
b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt });
|
||||||
|
});
|
||||||
|
|
||||||
builder.ConfigureCatalog();
|
builder.ConfigureCatalog();
|
||||||
builder.ConfigureInventory();
|
builder.ConfigureInventory();
|
||||||
builder.ConfigurePurchases();
|
builder.ConfigurePurchases();
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,6 @@ private sealed class DesignTimeTenantContext : ITenantContext
|
||||||
public bool IsAuthenticated => false;
|
public bool IsAuthenticated => false;
|
||||||
public bool IsSuperAdmin => true;
|
public bool IsSuperAdmin => true;
|
||||||
public bool IsTenantOverride => false;
|
public bool IsTenantOverride => false;
|
||||||
|
public Guid? UserId => null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Phase8b — org_audit_log: per-tenant журнал мутаций.
|
||||||
|
///
|
||||||
|
/// Пишется через OrgAuditInterceptor (SaveChangesInterceptor) на
|
||||||
|
/// отслеживаемые типы. Tenant-scoped — query-filter ограничивает выборку
|
||||||
|
/// своей оргой; SuperAdmin без override видит всё. ChangesJson — jsonb
|
||||||
|
/// формата { "field": { "before": ..., "after": ... } }.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260528210000_Phase8b_OrgAuditLog")]
|
||||||
|
public partial class Phase8b_OrgAuditLog : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS public.org_audit_log (
|
||||||
|
""Id"" uuid PRIMARY KEY,
|
||||||
|
""OrganizationId"" uuid NOT NULL,
|
||||||
|
""UserId"" uuid,
|
||||||
|
""Action"" varchar(20) NOT NULL,
|
||||||
|
""EntityType"" varchar(100) NOT NULL,
|
||||||
|
""EntityId"" uuid,
|
||||||
|
""ChangesJson"" jsonb NOT NULL,
|
||||||
|
""CreatedAt"" timestamp with time zone NOT NULL,
|
||||||
|
""UpdatedAt"" timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_CreatedAt""
|
||||||
|
ON public.org_audit_log (""OrganizationId"", ""CreatedAt"");
|
||||||
|
CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_EntityType_EntityId""
|
||||||
|
ON public.org_audit_log (""OrganizationId"", ""EntityType"", ""EntityId"");
|
||||||
|
CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_UserId_CreatedAt""
|
||||||
|
ON public.org_audit_log (""OrganizationId"", ""UserId"", ""CreatedAt"");
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"DROP TABLE IF EXISTS public.org_audit_log;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>EF Core <c>SaveChangesInterceptor</c>, который снимает diff
|
||||||
|
/// отслеживаемых сущностей и пишет строки <see cref="OrgAuditLog"/>.
|
||||||
|
///
|
||||||
|
/// Срабатывает в <c>SavingChanges</c> (до commit): мы не плодим лог для
|
||||||
|
/// rolled-back изменений. Лог записывается в тот же DbContext в коллекции
|
||||||
|
/// будущей SaveChanges'ов — НЕ как отдельный round-trip; одна транзакция,
|
||||||
|
/// одна атомарность. Параметр <c>SkipAudit</c> позволяет отключить
|
||||||
|
/// инструментацию для специфических операций (seed, миграция).
|
||||||
|
///
|
||||||
|
/// Отслеживаемые типы — белый список (см. <c>IsTracked</c>); расширять
|
||||||
|
/// сюда, не через рефлексию по всем DbSet'ам, чтобы аудит-лог не
|
||||||
|
/// раздувался самой меняющей его строкой.</summary>
|
||||||
|
public sealed class OrgAuditInterceptor : SaveChangesInterceptor
|
||||||
|
{
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public OrgAuditInterceptor(ITenantContext tenant) => _tenant = tenant;
|
||||||
|
|
||||||
|
public override InterceptionResult<int> SavingChanges(
|
||||||
|
DbContextEventData eventData, InterceptionResult<int> result)
|
||||||
|
{
|
||||||
|
WriteAuditEntries(eventData.Context);
|
||||||
|
return base.SavingChanges(eventData, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||||
|
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
WriteAuditEntries(eventData.Context);
|
||||||
|
return base.SavingChangesAsync(eventData, result, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteAuditEntries(DbContext? ctx)
|
||||||
|
{
|
||||||
|
if (ctx is not AppDbContext db) return;
|
||||||
|
if (db.SkipAudit) return;
|
||||||
|
// Аудитные записи не аудируем (бесконечная рекурсия).
|
||||||
|
var entries = db.ChangeTracker.Entries()
|
||||||
|
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
|
||||||
|
.Where(e => IsTracked(e.Entity))
|
||||||
|
.ToList();
|
||||||
|
if (entries.Count == 0) return;
|
||||||
|
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return; // системные операции (SuperAdmin без override / seed)
|
||||||
|
|
||||||
|
var userId = _tenant.UserId;
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var (action, diff) = ComputeDiff(entry);
|
||||||
|
// Diff может быть пустым (Update без реальных изменений) — пропускаем.
|
||||||
|
if (action == "update" && diff.Count == 0) continue;
|
||||||
|
var id = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey())?.CurrentValue as Guid?;
|
||||||
|
db.Add(new OrgAuditLog
|
||||||
|
{
|
||||||
|
OrganizationId = orgId.Value,
|
||||||
|
UserId = userId,
|
||||||
|
Action = action,
|
||||||
|
EntityType = entry.Entity.GetType().Name,
|
||||||
|
EntityId = id,
|
||||||
|
ChangesJson = JsonSerializer.Serialize(diff),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTracked(object entity) => entity switch
|
||||||
|
{
|
||||||
|
Supply or SupplyLine => true,
|
||||||
|
SupplierReturn or SupplierReturnLine => true,
|
||||||
|
RetailSale or RetailSaleLine => true,
|
||||||
|
Demand or DemandLine => true,
|
||||||
|
Product or ProductPrice or ProductBarcode => true,
|
||||||
|
Counterparty => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (string Action, Dictionary<string, object> Diff) ComputeDiff(EntityEntry entry)
|
||||||
|
{
|
||||||
|
var diff = new Dictionary<string, object>();
|
||||||
|
switch (entry.State)
|
||||||
|
{
|
||||||
|
case EntityState.Added:
|
||||||
|
foreach (var p in entry.Properties)
|
||||||
|
{
|
||||||
|
if (IsSkippable(p.Metadata.Name)) continue;
|
||||||
|
if (p.CurrentValue is null) continue;
|
||||||
|
diff[p.Metadata.Name] = new { after = p.CurrentValue };
|
||||||
|
}
|
||||||
|
return ("create", diff);
|
||||||
|
case EntityState.Modified:
|
||||||
|
foreach (var p in entry.Properties)
|
||||||
|
{
|
||||||
|
if (IsSkippable(p.Metadata.Name)) continue;
|
||||||
|
if (!p.IsModified) continue;
|
||||||
|
if (Equals(p.OriginalValue, p.CurrentValue)) continue;
|
||||||
|
diff[p.Metadata.Name] = new { before = p.OriginalValue, after = p.CurrentValue };
|
||||||
|
}
|
||||||
|
return ("update", diff);
|
||||||
|
case EntityState.Deleted:
|
||||||
|
foreach (var p in entry.Properties)
|
||||||
|
{
|
||||||
|
if (IsSkippable(p.Metadata.Name)) continue;
|
||||||
|
if (p.OriginalValue is null) continue;
|
||||||
|
diff[p.Metadata.Name] = new { before = p.OriginalValue };
|
||||||
|
}
|
||||||
|
return ("delete", diff);
|
||||||
|
default:
|
||||||
|
return ("noop", diff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Поля, которые не имеет смысла включать в diff — служебные
|
||||||
|
/// и шумные. Tenant, timestamps, навигационные FK дублируются с самими
|
||||||
|
/// сущностями, а CreatedAt/UpdatedAt меняются при каждом сохранении.</summary>
|
||||||
|
private static bool IsSkippable(string name) => name switch
|
||||||
|
{
|
||||||
|
"OrganizationId" or "CreatedAt" or "UpdatedAt" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
|
||||||
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
|
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
|
||||||
import { DemandsPage } from '@/pages/DemandsPage'
|
import { DemandsPage } from '@/pages/DemandsPage'
|
||||||
import { DemandEditPage } from '@/pages/DemandEditPage'
|
import { DemandEditPage } from '@/pages/DemandEditPage'
|
||||||
|
import { OrgAuditLogPage } from '@/pages/OrgAuditLogPage'
|
||||||
import { SalesReportPage } from '@/pages/SalesReportPage'
|
import { SalesReportPage } from '@/pages/SalesReportPage'
|
||||||
import { StockReportPage } from '@/pages/StockReportPage'
|
import { StockReportPage } from '@/pages/StockReportPage'
|
||||||
import { ProfitReportPage } from '@/pages/ProfitReportPage'
|
import { ProfitReportPage } from '@/pages/ProfitReportPage'
|
||||||
|
|
@ -141,6 +142,7 @@ export default function App() {
|
||||||
<Route path="/sales/demands" element={<DemandsPage />} />
|
<Route path="/sales/demands" element={<DemandsPage />} />
|
||||||
<Route path="/sales/demands/new" element={<DemandEditPage />} />
|
<Route path="/sales/demands/new" element={<DemandEditPage />} />
|
||||||
<Route path="/sales/demands/:id" element={<DemandEditPage />} />
|
<Route path="/sales/demands/:id" element={<DemandEditPage />} />
|
||||||
|
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}><OrgAuditLogPage /></RoleGuard>} />
|
||||||
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
|
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
|
||||||
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
|
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
|
||||||
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />
|
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
||||||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send,
|
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||||
|
|
@ -129,6 +129,7 @@ function buildNav(roles: string[]): NavSection[] {
|
||||||
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
|
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
|
||||||
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
|
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
|
||||||
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
|
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
|
||||||
|
{ to: '/audit-log', icon: FileText, label: 'Журнал изменений' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
125
src/food-market.web/src/pages/OrgAuditLogPage.tsx
Normal file
125
src/food-market.web/src/pages/OrgAuditLogPage.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
import { DataTable } from '@/components/DataTable'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
|
import { Field, Select } from '@/components/Field'
|
||||||
|
import type { PagedResult } from '@/lib/types'
|
||||||
|
|
||||||
|
interface AuditRow {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
userId: string | null
|
||||||
|
userName: string | null
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string | null
|
||||||
|
changesJson: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS = [
|
||||||
|
{ value: '', label: 'Все' },
|
||||||
|
{ value: 'create', label: 'Создание' },
|
||||||
|
{ value: 'update', label: 'Изменение' },
|
||||||
|
{ value: 'delete', label: 'Удаление' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ENTITY_TYPES = [
|
||||||
|
{ value: '', label: 'Все' },
|
||||||
|
{ value: 'Supply', label: 'Приёмки' },
|
||||||
|
{ value: 'SupplierReturn', label: 'Возвраты поставщикам' },
|
||||||
|
{ value: 'RetailSale', label: 'Чеки розничные' },
|
||||||
|
{ value: 'Demand', label: 'Оптовые отгрузки' },
|
||||||
|
{ value: 'Product', label: 'Товары' },
|
||||||
|
{ value: 'Counterparty', label: 'Контрагенты' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Журнал мутаций tenant'а — кто, что и когда менял. Read-only.
|
||||||
|
* Запись делает OrgAuditInterceptor автоматически на каждом SaveChanges. */
|
||||||
|
export function OrgAuditLogPage() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [entityType, setEntityType] = useState('')
|
||||||
|
const [action, setAction] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||||
|
if (entityType) params.set('entityType', entityType)
|
||||||
|
if (action) params.set('action', action)
|
||||||
|
|
||||||
|
const rep = useQuery({
|
||||||
|
queryKey: ['audit-log', page, entityType, action],
|
||||||
|
queryFn: async () => (await api.get<PagedResult<AuditRow>>(`/api/admin/audit-log?${params}`)).data,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = (rep.data?.items ?? []).filter((r) => {
|
||||||
|
if (!search) return true
|
||||||
|
const s = search.toLowerCase()
|
||||||
|
return (r.userName ?? '').toLowerCase().includes(s)
|
||||||
|
|| r.entityType.toLowerCase().includes(s)
|
||||||
|
|| r.changesJson.toLowerCase().includes(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title="Журнал изменений"
|
||||||
|
description={rep.data ? `${rep.data.total.toLocaleString('ru')} событий` : 'Все мутации документов и справочников.'}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<SearchBar value={search} onChange={setSearch} placeholder="По имени, типу, тексту…" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={rep.data && rep.data.total > 0 && (
|
||||||
|
<Pagination page={page} pageSize={rep.data.pageSize} total={rep.data.total} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 mb-3 max-w-2xl">
|
||||||
|
<Field label="Тип сущности">
|
||||||
|
<Select value={entityType} onChange={(e) => { setEntityType(e.target.value); setPage(1) }}>
|
||||||
|
{ENTITY_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Действие">
|
||||||
|
<Select value={action} onChange={(e) => { setAction(e.target.value); setPage(1) }}>
|
||||||
|
{ACTIONS.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
rows={filtered}
|
||||||
|
isLoading={rep.isLoading}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
columns={[
|
||||||
|
{ header: 'Когда', width: '170px', cell: (r) => new Date(r.createdAt).toLocaleString('ru') },
|
||||||
|
{ header: 'Кто', width: '180px', cell: (r) => r.userName ?? <span className="text-slate-400">система</span> },
|
||||||
|
{ header: 'Тип', width: '140px', cell: (r) => r.entityType },
|
||||||
|
{ header: 'Действие', width: '110px', cell: (r) => (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
r.action === 'create' ? 'bg-green-50 text-green-700' :
|
||||||
|
r.action === 'delete' ? 'bg-red-50 text-red-700' :
|
||||||
|
'bg-slate-100 text-slate-700'
|
||||||
|
}`}>{r.action}</span>
|
||||||
|
)},
|
||||||
|
{ header: 'EntityId', width: '110px', cell: (r) => (
|
||||||
|
r.entityId ? <span className="font-mono text-xs text-slate-500">{r.entityId.slice(0, 8)}</span> : '—'
|
||||||
|
)},
|
||||||
|
{ header: 'Изменения', cell: (r) => (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-slate-500 hover:text-slate-700">показать diff</summary>
|
||||||
|
<pre className="mt-1 p-2 bg-slate-50 dark:bg-slate-800 rounded text-[10px] max-w-2xl overflow-x-auto">{
|
||||||
|
(() => {
|
||||||
|
try { return JSON.stringify(JSON.parse(r.changesJson), null, 2) }
|
||||||
|
catch { return r.changesJson }
|
||||||
|
})()
|
||||||
|
}</pre>
|
||||||
|
</details>
|
||||||
|
)},
|
||||||
|
]}
|
||||||
|
empty="Событий пока нет."
|
||||||
|
/>
|
||||||
|
</ListPageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
tests/food-market.IntegrationTests/OrgAuditLogTests.cs
Normal file
83
tests/food-market.IntegrationTests/OrgAuditLogTests.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class OrgAuditLogTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public OrgAuditLogTests(ApiFactory factory) => _factory = factory;
|
||||||
|
private static string RandomBarcode()
|
||||||
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Product_create_writes_audit_entry()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"audit-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
|
||||||
|
var pid = await api.CreateProductAsync(refs, $"AUD-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
var log = await api.GetJsonAsync("/api/admin/audit-log?entityType=Product&pageSize=20");
|
||||||
|
var items = log.GetProperty("items").EnumerateArray().ToList();
|
||||||
|
var entry = items.First(x => x.GetProperty("entityId").GetString() == pid);
|
||||||
|
entry.GetProperty("action").GetString().Should().Be("create");
|
||||||
|
entry.GetProperty("entityType").GetString().Should().Be("Product");
|
||||||
|
entry.GetProperty("changesJson").GetString().Should().Contain("\"after\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Counterparty_update_diff_contains_before_after()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"audit-upd-{Guid.NewGuid():N}");
|
||||||
|
var cid = await api.CreateCounterpartyAsync($"Init-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
// Обновляем — меняем имя.
|
||||||
|
var newName = $"Renamed-{Guid.NewGuid():N}";
|
||||||
|
var resp = await api.Http.PutAsJsonAsync($"/api/catalog/counterparties/{cid}", new
|
||||||
|
{
|
||||||
|
name = newName, legalName = (string?)null, type = 0,
|
||||||
|
bin = "987654321098", iin = (string?)null, taxNumber = (string?)null,
|
||||||
|
countryId = (string?)null, address = "Алматы", phone = "+77000000000",
|
||||||
|
email = "x@y.kz", bankName = (string?)null, bankAccount = (string?)null,
|
||||||
|
bik = (string?)null, contactPerson = "X", notes = (string?)null,
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var log = await api.GetJsonAsync($"/api/admin/audit-log?entityType=Counterparty&entityId={cid}");
|
||||||
|
var items = log.GetProperty("items").EnumerateArray().ToList();
|
||||||
|
items.Should().Contain(x => x.GetProperty("action").GetString() == "update");
|
||||||
|
var update = items.First(x => x.GetProperty("action").GetString() == "update");
|
||||||
|
var diff = update.GetProperty("changesJson").GetString()!;
|
||||||
|
diff.Should().Contain("\"before\"");
|
||||||
|
diff.Should().Contain("\"after\"");
|
||||||
|
diff.Should().Contain(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tenant_isolation_audit_log()
|
||||||
|
{
|
||||||
|
var a = new ApiActor(_factory.CreateClient());
|
||||||
|
var b = new ApiActor(_factory.CreateClient());
|
||||||
|
await a.SignupAndLoginAsync($"audit-iso-a-{Guid.NewGuid():N}");
|
||||||
|
await b.SignupAndLoginAsync($"audit-iso-b-{Guid.NewGuid():N}");
|
||||||
|
var refsA = await a.LoadRefsAsync();
|
||||||
|
|
||||||
|
var pidA = await a.CreateProductAsync(refsA, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
// A видит свою запись.
|
||||||
|
var logA = await a.GetJsonAsync($"/api/admin/audit-log?entityId={pidA}");
|
||||||
|
logA.GetProperty("items").EnumerateArray().Should().Contain(
|
||||||
|
x => x.GetProperty("entityId").GetString() == pidA);
|
||||||
|
|
||||||
|
// B не видит чужую запись (query-filter по OrganizationId).
|
||||||
|
var logB = await b.GetJsonAsync($"/api/admin/audit-log?entityId={pidA}");
|
||||||
|
logB.GetProperty("items").EnumerateArray().Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,4 +10,5 @@ public sealed class FakeTenantContext : ITenantContext
|
||||||
public bool IsAuthenticated { get; set; } = true;
|
public bool IsAuthenticated { get; set; } = true;
|
||||||
public bool IsSuperAdmin { get; set; }
|
public bool IsSuperAdmin { get; set; }
|
||||||
public bool IsTenantOverride { get; set; }
|
public bool IsTenantOverride { get; set; }
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue