Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/Business/Grand.Business.Common/Services/Audit/AuditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using Grand.Business.Core.Interfaces.Common.Audit;
using Grand.Data;
using Grand.Data.Audit;
using Grand.Domain;
using Grand.Domain.Audit;
using Grand.Infrastructure.Audit;
using Grand.Infrastructure.Caching;
using Grand.Infrastructure.Caching.Constants;
using Grand.Mediator;
using System.Runtime.CompilerServices;

namespace Grand.Business.Common.Services.Audit;

public class AuditService(
IRepository<AuditEntry> repository,
IAuditEntryWriter writer,
IMediator mediator,
ICacheBase cacheBase) : IAuditService
{
private const int StreamPageSize = 1000;

//a distinct over the whole log; a type that appears meanwhile shows up in the filter a little later
private const int EntityTypesCacheMinutes = 10;

public Task<IPagedList<AuditEntry>> GetEntries(AuditEntrySearch search, int pageIndex = 0,
int pageSize = int.MaxValue)
{
return repository.PagedAsync(Query(search), pageIndex, pageSize);
}

public Task<AuditEntry> GetById(string id)
{
return repository.GetByIdAsync(id);
}

public async IAsyncEnumerable<AuditEntry> StreamEntries(AuditEntrySearch search,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
//entries written while the export runs would shift the pages, so the export stops at its start time
var now = DateTime.UtcNow;
var bounded = new AuditEntrySearch {
FromUtc = search.FromUtc,
ToUtc = search.ToUtc == null || search.ToUtc > now ? now : search.ToUtc,
ActorEmail = search.ActorEmail,
Source = search.Source,
Kind = search.Kind,
EntityType = search.EntityType,
EntityId = search.EntityId,
EntityNames = search.EntityNames,
StoreId = search.StoreId,
OperationId = search.OperationId
};

//each page continues below the time of the previous one (on the CreatedOnUtc_Id index), skipping only the
//entries of that same time already yielded - one flush stamps many entries alike - so no page counts the
//whole result or skips everything before it
var skip = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var page = await repository.ToListAsync(Query(bounded).Skip(skip).Take(StreamPageSize),
cancellationToken);
foreach (var entry in page)
yield return entry;

if (page.Count < StreamPageSize)
yield break;

var last = page[^1].CreatedOnUtc;
skip = (bounded.ToUtc == last ? skip : 0) + page.Count(e => e.CreatedOnUtc == last);
bounded.ToUtc = last;
}
}

public Task<IList<string>> GetEntityTypes()
{
return cacheBase.GetAsync(CacheKey.AUDIT_ENTITY_TYPES_KEY, async () =>
{
var types = await repository.ToListAsync(repository.Table.Select(x => x.EntityType).Distinct());
return (IList<string>)types.Where(t => !string.IsNullOrEmpty(t)).OrderBy(t => t, StringComparer.Ordinal)
.ToList();
}, EntityTypesCacheMinutes);
}

public async Task InsertEvent(AuditEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);

if (entry.CreatedOnUtc == default)
entry.CreatedOnUtc = DateTime.UtcNow;

await writer.InsertManyAsync([entry]);
await mediator.Publish(new AuditEntryCreated(entry));
}

public async Task<int> DeleteOlderThan(DateTime createdBeforeUtc)
{
var count = await repository.CountAsync(repository.Table.Where(x => x.CreatedOnUtc < createdBeforeUtc));
if (count > 0)
await repository.DeleteManyAsync(x => x.CreatedOnUtc < createdBeforeUtc);
return count;
}

private IQueryable<AuditEntry> Query(AuditEntrySearch search)
{
var query = repository.Table;
if (search.FromUtc.HasValue)
query = query.Where(x => x.CreatedOnUtc >= search.FromUtc.Value);
if (search.ToUtc.HasValue)
query = query.Where(x => x.CreatedOnUtc <= search.ToUtc.Value);
if (!string.IsNullOrEmpty(search.ActorEmail))
{
var email = search.ActorEmail.ToLowerInvariant();
query = query.Where(x => x.ActorEmail != null && x.ActorEmail.ToLower().Contains(email));
}

if (search.Source.HasValue)
query = query.Where(x => x.Source == search.Source.Value);
if (search.Kind.HasValue)
query = query.Where(x => x.Kind == search.Kind.Value);
if (!string.IsNullOrEmpty(search.EntityType))
query = query.Where(x => x.EntityType == search.EntityType);
if (!string.IsNullOrEmpty(search.EntityId))
query = query.Where(x => x.EntityId == search.EntityId);
if (search.EntityNames is { Count: > 0 })
{
var names = search.EntityNames.ToList();
query = query.Where(x => names.Contains(x.EntityName));
}

if (!string.IsNullOrEmpty(search.StoreId))
query = query.Where(x => x.StoreId == search.StoreId || x.EntityStoreId == search.StoreId);
if (!string.IsNullOrEmpty(search.OperationId))
query = query.Where(x => x.OperationId == search.OperationId);

return query.OrderByDescending(x => x.CreatedOnUtc).ThenByDescending(x => x.Id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public virtual IEnumerable<Permission> GetPermissions()
StandardPermission.ManagePictures,
StandardPermission.HtmlEditorManagePictures,
StandardPermission.ManageScheduleTasks,
StandardPermission.ManageAuditLog,
StandardPermission.DisplayPrices,
StandardPermission.EnableShoppingCart,
StandardPermission.EnableWishlist,
Expand Down Expand Up @@ -154,6 +155,7 @@ public virtual IEnumerable<DefaultPermission> GetDefaultPermissions()
StandardPermission.ManagePictures,
StandardPermission.HtmlEditorManagePictures,
StandardPermission.ManageScheduleTasks,
StandardPermission.ManageAuditLog,
StandardPermission.DisplayPrices,
StandardPermission.EnableShoppingCart,
StandardPermission.EnableWishlist,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Grand.Business.Common.Services.Addresses;
using Grand.Business.Common.Services.Audit;
using Grand.Business.Core.Interfaces.Common.Audit;
using Grand.Business.Common.Services.Configuration;
using Grand.Business.Common.Services.Directory;
using Grand.Business.Common.Services.ExportImport;
Expand Down Expand Up @@ -51,6 +53,7 @@ private static void RegisterCommonService(IServiceCollection serviceCollection)
serviceCollection.AddScoped<IAddressAttributeParser, AddressAttributeParser>();
serviceCollection.AddScoped<IAddressAttributeService, AddressAttributeService>();
serviceCollection.AddScoped<IHistoryService, HistoryService>();
serviceCollection.AddScoped<IAuditService, AuditService>();
serviceCollection.AddScoped<IPdfService, PdfService>();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ public CustomerLoginFailedEvent(Customer customer)
Customer = customer;
}

public CustomerLoginFailedEvent(Customer customer, string reason) : this(customer)
{
Reason = reason;
}

/// <summary>
/// Customer
/// </summary>
public Customer Customer {
get;
private set;
}

/// <summary>
/// Why the sign-in failed, e.g. wrong credentials or a wrong two-factor code
/// </summary>
public string Reason { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Grand.Domain.Audit;

namespace Grand.Business.Core.Interfaces.Common.Audit;

/// <summary>
/// Filters of the audit log; null or empty means "any"
/// </summary>
public class AuditEntrySearch
{
public DateTime? FromUtc { get; set; }
public DateTime? ToUtc { get; set; }
public string ActorEmail { get; set; }
public AuditSource? Source { get; set; }
public AuditKind? Kind { get; set; }
public string EntityType { get; set; }
public string EntityId { get; set; }

/// <summary>
/// Any of these entity names, e.g. the setting classes one settings page saves
/// </summary>
public IList<string> EntityNames { get; set; }

/// <summary>
/// Entries made by that store's staff or changing a record of that store
/// </summary>
public string StoreId { get; set; }
public string OperationId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Grand.Domain;
using Grand.Domain.Audit;

namespace Grand.Business.Core.Interfaces.Common.Audit;

/// <summary>
/// Reads the staff audit trail and records events. Entries cannot be updated or deleted one by one;
/// only the retention task removes old ones.
/// </summary>
public interface IAuditService
{
/// <summary>
/// Gets entries matching the search, newest first
/// </summary>
Task<IPagedList<AuditEntry>> GetEntries(AuditEntrySearch search, int pageIndex = 0, int pageSize = int.MaxValue);

Task<AuditEntry> GetById(string id);

/// <summary>
/// Yields every entry matching the search, newest first, a page at a time (for exports)
/// </summary>
IAsyncEnumerable<AuditEntry> StreamEntries(AuditEntrySearch search, CancellationToken cancellationToken);

/// <summary>
/// Gets the distinct entity types present in the log, sorted
/// </summary>
Task<IList<string>> GetEntityTypes();

/// <summary>
/// Records an event that is not a data change (a sign-in, an export, an impersonation)
/// </summary>
Task InsertEvent(AuditEntry entry);

/// <summary>
/// Deletes entries created before the given time; returns how many were deleted
/// </summary>
Task<int> DeleteOlderThan(DateTime createdBeforeUtc);
}
33 changes: 33 additions & 0 deletions src/Core/Grand.Data/Audit/AuditActor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Grand.Domain.Audit;

namespace Grand.Data.Audit;

/// <summary>
/// Who made the writes of the current request, pushed in by the web layer
/// </summary>
public record AuditActor(
AuditSource Source,
string CustomerId,
string Email,
string ImpersonatedCustomerId,
string StoreId,
string VendorId,
string IpAddress,
string UserAgent,
string Action,
string OperationId)
{
public void Stamp(AuditEntry entry)
{
entry.Source = Source;
entry.ActorCustomerId = CustomerId;
entry.ActorEmail = Email;
entry.ImpersonatedCustomerId = ImpersonatedCustomerId;
entry.StoreId = StoreId;
entry.VendorId = VendorId;
entry.IpAddress = IpAddress;
entry.UserAgent = UserAgent;
entry.Action = Action;
entry.OperationId = OperationId;
}
}
Loading
Loading