diff --git a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs new file mode 100644 index 0000000000..088b215293 --- /dev/null +++ b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs @@ -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 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> GetEntries(AuditEntrySearch search, int pageIndex = 0, + int pageSize = int.MaxValue) + { + return repository.PagedAsync(Query(search), pageIndex, pageSize); + } + + public Task GetById(string id) + { + return repository.GetByIdAsync(id); + } + + public async IAsyncEnumerable 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> GetEntityTypes() + { + return cacheBase.GetAsync(CacheKey.AUDIT_ENTITY_TYPES_KEY, async () => + { + var types = await repository.ToListAsync(repository.Table.Select(x => x.EntityType).Distinct()); + return (IList)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 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 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); + } +} diff --git a/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs b/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs index 250d926aa8..46fd3b640b 100644 --- a/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs +++ b/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs @@ -73,6 +73,7 @@ public virtual IEnumerable GetPermissions() StandardPermission.ManagePictures, StandardPermission.HtmlEditorManagePictures, StandardPermission.ManageScheduleTasks, + StandardPermission.ManageAuditLog, StandardPermission.DisplayPrices, StandardPermission.EnableShoppingCart, StandardPermission.EnableWishlist, @@ -154,6 +155,7 @@ public virtual IEnumerable GetDefaultPermissions() StandardPermission.ManagePictures, StandardPermission.HtmlEditorManagePictures, StandardPermission.ManageScheduleTasks, + StandardPermission.ManageAuditLog, StandardPermission.DisplayPrices, StandardPermission.EnableShoppingCart, StandardPermission.EnableWishlist, diff --git a/src/Business/Grand.Business.Common/Startup/StartupApplication.cs b/src/Business/Grand.Business.Common/Startup/StartupApplication.cs index 4f474d1b0e..dc12a00cdb 100644 --- a/src/Business/Grand.Business.Common/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Common/Startup/StartupApplication.cs @@ -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; @@ -51,6 +53,7 @@ private static void RegisterCommonService(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); } diff --git a/src/Business/Grand.Business.Core/Events/Customers/CustomerLoginFailedEvent.cs b/src/Business/Grand.Business.Core/Events/Customers/CustomerLoginFailedEvent.cs index 22c4a0600c..19177f1af8 100644 --- a/src/Business/Grand.Business.Core/Events/Customers/CustomerLoginFailedEvent.cs +++ b/src/Business/Grand.Business.Core/Events/Customers/CustomerLoginFailedEvent.cs @@ -13,6 +13,11 @@ public CustomerLoginFailedEvent(Customer customer) Customer = customer; } + public CustomerLoginFailedEvent(Customer customer, string reason) : this(customer) + { + Reason = reason; + } + /// /// Customer /// @@ -20,4 +25,9 @@ public Customer Customer { get; private set; } + + /// + /// Why the sign-in failed, e.g. wrong credentials or a wrong two-factor code + /// + public string Reason { get; } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs new file mode 100644 index 0000000000..0069d0d5c0 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs @@ -0,0 +1,28 @@ +using Grand.Domain.Audit; + +namespace Grand.Business.Core.Interfaces.Common.Audit; + +/// +/// Filters of the audit log; null or empty means "any" +/// +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; } + + /// + /// Any of these entity names, e.g. the setting classes one settings page saves + /// + public IList EntityNames { get; set; } + + /// + /// Entries made by that store's staff or changing a record of that store + /// + public string StoreId { get; set; } + public string OperationId { get; set; } +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/IAuditService.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/IAuditService.cs new file mode 100644 index 0000000000..5182a792af --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/IAuditService.cs @@ -0,0 +1,38 @@ +using Grand.Domain; +using Grand.Domain.Audit; + +namespace Grand.Business.Core.Interfaces.Common.Audit; + +/// +/// Reads the staff audit trail and records events. Entries cannot be updated or deleted one by one; +/// only the retention task removes old ones. +/// +public interface IAuditService +{ + /// + /// Gets entries matching the search, newest first + /// + Task> GetEntries(AuditEntrySearch search, int pageIndex = 0, int pageSize = int.MaxValue); + + Task GetById(string id); + + /// + /// Yields every entry matching the search, newest first, a page at a time (for exports) + /// + IAsyncEnumerable StreamEntries(AuditEntrySearch search, CancellationToken cancellationToken); + + /// + /// Gets the distinct entity types present in the log, sorted + /// + Task> GetEntityTypes(); + + /// + /// Records an event that is not a data change (a sign-in, an export, an impersonation) + /// + Task InsertEvent(AuditEntry entry); + + /// + /// Deletes entries created before the given time; returns how many were deleted + /// + Task DeleteOlderThan(DateTime createdBeforeUtc); +} diff --git a/src/Core/Grand.Data/Audit/AuditActor.cs b/src/Core/Grand.Data/Audit/AuditActor.cs new file mode 100644 index 0000000000..1edc41a91b --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditActor.cs @@ -0,0 +1,33 @@ +using Grand.Domain.Audit; + +namespace Grand.Data.Audit; + +/// +/// Who made the writes of the current request, pushed in by the web layer +/// +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; + } +} diff --git a/src/Core/Grand.Data/Audit/AuditFilterId.cs b/src/Core/Grand.Data/Audit/AuditFilterId.cs new file mode 100644 index 0000000000..a1f7871a8a --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditFilterId.cs @@ -0,0 +1,115 @@ +using Grand.Domain; +using System.Linq.Expressions; +using System.Reflection; + +namespace Grand.Data.Audit; + +/// +/// Reads what a repository write touches from its expressions: the document id of a filter, and the field paths. +/// +public static class AuditFilterId +{ + /// + /// Recognises x => x.Id == value (either operand order) and evaluates the value + /// + public static bool TryGetId(Expression> filter, out string id) where T : BaseEntity + { + id = null; + if (filter?.Body is not BinaryExpression { NodeType: ExpressionType.Equal } equal) + return false; + + var parameter = filter.Parameters[0]; + if (IsIdOf(equal.Left, parameter)) + return TryEvaluate(equal.Right, out id); + if (IsIdOf(equal.Right, parameter)) + return TryEvaluate(equal.Left, out id); + return false; + } + + /// + /// The dotted member path of x => x.A.B, or null when the expression is not a plain member chain + /// + public static string MemberPath(Expression> expression) + { + return MemberPath((LambdaExpression)expression); + } + + public static string MemberPath(LambdaExpression expression) + { + var names = new Stack(); + var current = StripConvert(expression.Body); + while (current is MemberExpression member) + { + names.Push(member.Member.Name); + current = StripConvert(member.Expression); + } + + return current == expression.Parameters[0] && names.Count > 0 ? string.Join(".", names) : null; + } + + /// + /// The member paths an update builder sets + /// + public static IList MemberPaths(UpdateBuilder builder) + { + return builder.ExpressionFields.Select(f => MemberPath(f.Expression)).ToList(); + } + + private static bool IsIdOf(Expression expression, ParameterExpression parameter) + { + return StripConvert(expression) is MemberExpression { Member.Name: nameof(BaseEntity.Id) } member + && StripConvert(member.Expression) == parameter; + } + + private static Expression StripConvert(Expression expression) + { + while (expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary) + expression = unary.Operand; + return expression; + } + + private static bool TryEvaluate(Expression expression, out string value) + { + value = null; + if (!TryEvaluateObject(StripConvert(expression), out var result)) + return false; + + value = result?.ToString(); + return !string.IsNullOrEmpty(value); + } + + //constants and captured variables only, without compiling the expression + private static bool TryEvaluateObject(Expression expression, out object value) + { + value = null; + switch (expression) + { + case ConstantExpression constant: + value = constant.Value; + return true; + case MemberExpression member: + object target = null; + if (member.Expression != null && !TryEvaluateObject(StripConvert(member.Expression), out target)) + return false; + if (member.Expression != null && target == null) + return false; + try + { + value = member.Member switch { + FieldInfo field => field.GetValue(target), + PropertyInfo property => property.GetValue(target), + _ => null + }; + } + catch (Exception) + { + //a getter that throws: the write is recorded as a bulk write instead of failing + return false; + } + + return member.Member is FieldInfo or PropertyInfo; + default: + return false; + } + } +} diff --git a/src/Core/Grand.Data/Audit/AuditIndexes.cs b/src/Core/Grand.Data/Audit/AuditIndexes.cs new file mode 100644 index 0000000000..b5e517f7dd --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditIndexes.cs @@ -0,0 +1,25 @@ +using Grand.Domain.Audit; + +namespace Grand.Data.Audit; + +/// +/// The indexes of the audit log, shared by the installer and the 2.5 migration. Each ends with the +/// "newest first, then id" order every query uses, so the server never sorts in memory. +/// +public static class AuditIndexes +{ + public static async Task Create(IDatabaseContext databaseContext, IRepository repository) + { + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Descending(x => x.CreatedOnUtc).Descending(x => x.Id), + "CreatedOnUtc_Id"); + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.EntityType).Ascending(x => x.EntityId) + .Descending(x => x.CreatedOnUtc).Descending(x => x.Id), "EntityType_EntityId_CreatedOnUtc_Id"); + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.ActorCustomerId).Descending(x => x.CreatedOnUtc) + .Descending(x => x.Id), "ActorCustomerId_CreatedOnUtc_Id"); + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.OperationId), "OperationId"); + } +} diff --git a/src/Core/Grand.Data/Audit/AuditSnapshot.cs b/src/Core/Grand.Data/Audit/AuditSnapshot.cs new file mode 100644 index 0000000000..aa16ef2f9b --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditSnapshot.cs @@ -0,0 +1,105 @@ +using Grand.Domain; +using Grand.Domain.Configuration; +using MongoDB.Bson; + +namespace Grand.Data.Audit; + +/// +/// The stored state of an entity as "path → value", so the audit code above the data layer never handles BSON. +/// An array element is keyed by its id, else by its key (user fields, once per store), else by position. +/// +public static class AuditSnapshot +{ + /// + /// The value of every binary field: the content is not compared, a written binary field is listed as changed + /// + public const string Binary = ""; + + /// + /// The field holding a setting's values as JSON, flattened field by field when it parses + /// + public const string SettingMetadata = "Metadata"; + + public static IDictionary Take(T entity) where T : BaseEntity + { + return Flatten(entity.ToBsonDocument(), typeof(T) == typeof(Setting)); + } + + public static IDictionary Flatten(BsonDocument document, bool isSetting) + { + if (document == null) + return null; + + var leaves = new Dictionary(); + foreach (var element in document.Where(e => e.Name != "_id")) + { + var value = element.Value; + if (isSetting && element.Name == SettingMetadata && value.IsString) + value = TryParse(value.AsString) ?? value; + + Flatten(element.Name, value, leaves); + } + + return leaves; + } + + private static void Flatten(string path, BsonValue value, Dictionary leaves) + { + switch (value) + { + case BsonNull or BsonArray { Count: 0 }: + break; + //an element's id is already in its path + case BsonDocument document: + foreach (var element in document.Where(e => e.Name != "_id")) + Flatten($"{path}.{element.Name}", element.Value, leaves); + break; + case BsonArray array when array.Any(v => v.IsBsonDocument || v.IsBsonArray): + for (var i = 0; i < array.Count; i++) + Flatten($"{path}[{ElementKey(array[i], i)}]", array[i], leaves); + break; + default: + //a scalar, or a list of scalars kept as one value; when element ids repeat, the first wins + leaves.TryAdd(path, Format(value)); + break; + } + } + + private static string ElementKey(BsonValue element, int index) + { + if (element is not BsonDocument document) + return index.ToString(); + if ((document.TryGetValue("_id", out var id) || document.TryGetValue("Id", out id)) && !id.IsBsonNull) + return $"id={id}"; + if (!document.TryGetValue("Key", out var key) || !key.IsString || key.AsString == "") + return index.ToString(); + return document.TryGetValue("StoreId", out var storeId) && storeId.IsString && storeId.AsString != "" + ? $"{key.AsString}@{storeId.AsString}" + : key.AsString; + } + + private static BsonDocument TryParse(string json) + { + try + { + return BsonDocument.Parse(json); + } + catch (Exception) + { + return null; + } + } + + private static string Format(BsonValue value) + { + if (value.IsString) + return value.AsString; + if (value.IsBsonBinaryData) + return Binary; + if (value.IsValidDateTime) + return value.ToUniversalTime().ToString("o"); + if (value.IsBsonArray) + return value.ToJson(); + return value.ToString(); + } +} diff --git a/src/Core/Grand.Data/Audit/IAuditEntryWriter.cs b/src/Core/Grand.Data/Audit/IAuditEntryWriter.cs new file mode 100644 index 0000000000..deff381587 --- /dev/null +++ b/src/Core/Grand.Data/Audit/IAuditEntryWriter.cs @@ -0,0 +1,11 @@ +using Grand.Domain.Audit; + +namespace Grand.Data.Audit; + +/// +/// Writes audit entries in one batch, outside the audited repositories +/// +public interface IAuditEntryWriter +{ + Task InsertManyAsync(IReadOnlyCollection entries); +} diff --git a/src/Core/Grand.Data/Audit/IAuditTracker.cs b/src/Core/Grand.Data/Audit/IAuditTracker.cs new file mode 100644 index 0000000000..edd47da671 --- /dev/null +++ b/src/Core/Grand.Data/Audit/IAuditTracker.cs @@ -0,0 +1,39 @@ +using Grand.Domain; + +namespace Grand.Data.Audit; + +/// +/// Collects the staff writes of one request and turns them into audit entries. +/// Inactive (a pass-through) until the web layer activates it for a staff request. +/// +public interface IAuditTracker +{ + bool IsActive { get; } + + void Activate(AuditActor actor); + + bool IsAudited(Type entityType); + + /// + /// Called before a write of a single document (after it, for an insert). The first call for an id + /// snapshots the stored document with , which also reads the "after" state at the + /// flush; null means every field. + /// + Task BeforeWriteAsync(string id, bool isInsert, Func, Task>> loadMany, + IReadOnlyCollection writtenPaths = null) where T : BaseEntity; + + /// + /// Keeps the entity of a full update as its "after" state, so a later change by someone else before the flush + /// is not attributed to this request + /// + void AfterFullUpdate(T entity) where T : BaseEntity; + + void RecordBulk(string operation, string filter) where T : BaseEntity; + + Task FlushAsync(); + + /// + /// Flushes and stops tracking at the end of the request scope; later writes are not audited + /// + Task CompleteAsync(); +} diff --git a/src/Core/Grand.Data/Audit/RepositoryAudit.cs b/src/Core/Grand.Data/Audit/RepositoryAudit.cs new file mode 100644 index 0000000000..7424f40ddd --- /dev/null +++ b/src/Core/Grand.Data/Audit/RepositoryAudit.cs @@ -0,0 +1,75 @@ +using Grand.Domain; +using System.Linq.Expressions; + +namespace Grand.Data.Audit; + +/// +/// The audit hooks shared by the Mongo and LiteDB auditing repositories +/// +public sealed class RepositoryAudit(IAuditTracker tracker, Func, Task>> loadMany) + where T : BaseEntity +{ + private bool Tracking => tracker.IsActive && tracker.IsAudited(typeof(T)); + + public Task Inserted(T entity) + { + return Tracking ? tracker.BeforeWriteAsync(entity.Id, true, loadMany) : Task.CompletedTask; + } + + /// + /// A full update or a delete of one document + /// + public Task BeforeWrite(T entity) + { + return Tracking ? tracker.BeforeWriteAsync(entity.Id, false, loadMany) : Task.CompletedTask; + } + + public void AfterFullUpdate(T entity) + { + if (Tracking) + tracker.AfterFullUpdate(entity); + } + + /// + /// A write of one field (or one collection) of one document; an empty id means every document + /// + public Task BeforeField(string id, LambdaExpression field, string operation) + { + if (!Tracking) + return Task.CompletedTask; + + if (string.IsNullOrEmpty(id)) + { + tracker.RecordBulk(operation, field.ToString()); + return Task.CompletedTask; + } + + return tracker.BeforeWriteAsync(id, false, loadMany, Paths([AuditFilterId.MemberPath(field)])); + } + + public Task BeforeUpdateOne(Expression> filter, UpdateBuilder builder) + { + if (!Tracking) + return Task.CompletedTask; + + if (!AuditFilterId.TryGetId(filter, out var id)) + { + tracker.RecordBulk("UpdateOneAsync", filter.ToString()); + return Task.CompletedTask; + } + + return tracker.BeforeWriteAsync(id, false, loadMany, Paths(AuditFilterId.MemberPaths(builder))); + } + + public void Bulk(string operation, string filter) + { + if (Tracking) + tracker.RecordBulk(operation, filter); + } + + //an unreadable expression means "any field may have changed" + private static IReadOnlyCollection Paths(IList paths) + { + return paths.Count == 0 || paths.Any(p => p == null) ? null : paths.ToList(); + } +} diff --git a/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs b/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs new file mode 100644 index 0000000000..5586c2aa2f --- /dev/null +++ b/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs @@ -0,0 +1,113 @@ +using Grand.Data.Audit; +using Grand.Domain; +using LiteDB; +using System.Linq.Expressions; + +namespace Grand.Data.LiteDb; + +/// +/// LiteDB repository that reports staff writes to the audit tracker +/// +public class AuditingLiteDBRepository : LiteDBRepository where T : BaseEntity +{ + private readonly RepositoryAudit _audit; + + public AuditingLiteDBRepository(LiteDatabase database, IAuditInfoProvider auditInfoProvider, + IAuditTracker tracker) : base(database, auditInfoProvider) + { + _audit = new RepositoryAudit(tracker, LoadMany); + } + + //used before installation, when no database is registered yet + public AuditingLiteDBRepository(IAuditInfoProvider auditInfoProvider, IAuditTracker tracker) + : base(auditInfoProvider) + { + _audit = new RepositoryAudit(tracker, LoadMany); + } + + //by id, one at a time: Table would read the whole collection + private Task> LoadMany(IReadOnlyCollection ids) + { + IList found = ids.Select(id => Collection.FindById(new BsonValue(id))).Where(e => e != null).ToList(); + return Task.FromResult(found); + } + + public override async Task InsertAsync(T entity) + { + var inserted = await base.InsertAsync(entity); + await _audit.Inserted(entity); + return inserted; + } + + public override async Task UpdateAsync(T entity) + { + await _audit.BeforeWrite(entity); + var updated = await base.UpdateAsync(entity); + _audit.AfterFullUpdate(entity); + return updated; + } + + public override async Task UpdateField(string id, Expression> expression, U value) + { + await _audit.BeforeField(id, expression, nameof(UpdateField)); + await base.UpdateField(id, expression, value); + } + + public override async Task IncField(string id, Expression> expression, U value) + { + await _audit.BeforeField(id, expression, nameof(IncField)); + await base.IncField(id, expression, value); + } + + public override async Task UpdateOneAsync(Expression> filterexpression, + UpdateBuilder updateBuilder) + { + await _audit.BeforeUpdateOne(filterexpression, updateBuilder); + await base.UpdateOneAsync(filterexpression, updateBuilder); + } + + public override async Task UpdateManyAsync(Expression> filterexpression, + UpdateBuilder updateBuilder) + { + _audit.Bulk(nameof(UpdateManyAsync), filterexpression.ToString()); + await base.UpdateManyAsync(filterexpression, updateBuilder); + } + + public override async Task AddToCollectionField(string id, Expression>> field, U value) + { + await _audit.BeforeField(id, field, nameof(AddToCollectionField)); + await base.AddToCollectionField(id, field, value); + } + + public override async Task UpdateCollectionFieldItem(string id, Expression>> field, + Expression> elemFieldMatch, U value) + { + await _audit.BeforeField(id, field, nameof(UpdateCollectionFieldItem)); + await base.UpdateCollectionFieldItem(id, field, elemFieldMatch, value); + } + + public override async Task RemoveCollectionFieldItem(string id, Expression>> field, + Expression> elemFieldMatch) + { + await _audit.BeforeField(id, field, nameof(RemoveCollectionFieldItem)); + await base.RemoveCollectionFieldItem(id, field, elemFieldMatch); + } + + public override async Task DeleteAsync(T entity) + { + await _audit.BeforeWrite(entity); + return await base.DeleteAsync(entity); + } + + public override async Task DeleteManyAsync(Expression> filterExpression) + { + _audit.Bulk(nameof(DeleteManyAsync), filterExpression.ToString()); + await base.DeleteManyAsync(filterExpression); + } + + public override Task ClearAsync() + { + _audit.Bulk(nameof(ClearAsync), "all documents"); + return base.ClearAsync(); + } +} diff --git a/src/Core/Grand.Data/LiteDb/LiteDBAuditEntryWriter.cs b/src/Core/Grand.Data/LiteDb/LiteDBAuditEntryWriter.cs new file mode 100644 index 0000000000..52be85f6f0 --- /dev/null +++ b/src/Core/Grand.Data/LiteDb/LiteDBAuditEntryWriter.cs @@ -0,0 +1,17 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using LiteDB; + +namespace Grand.Data.LiteDb; + +/// +/// Resolves the database only when entries are written, so it can be registered before installation +/// +public class LiteDBAuditEntryWriter(Func database) : IAuditEntryWriter +{ + public Task InsertManyAsync(IReadOnlyCollection entries) + { + database().GetCollection(nameof(AuditEntry)).InsertBulk(entries); + return Task.CompletedTask; + } +} diff --git a/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs b/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs index ddb6ac4db3..eda3be3eae 100644 --- a/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs +++ b/src/Core/Grand.Data/LiteDb/LiteDBRepository.cs @@ -405,7 +405,7 @@ public virtual Task DeleteManyAsync(Expression> filterExpression) /// /// Clear entities /// - public Task ClearAsync() + public virtual Task ClearAsync() { Collection.DeleteAll(); return Task.CompletedTask; diff --git a/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs b/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs new file mode 100644 index 0000000000..547988f427 --- /dev/null +++ b/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs @@ -0,0 +1,112 @@ +using Grand.Data.Audit; +using Grand.Domain; +using MongoDB.Driver; +using System.Linq.Expressions; + +namespace Grand.Data.Mongo; + +/// +/// MongoDB repository that reports staff writes to the audit tracker. A subclass, so code checking +/// is MongoRepository<T> keeps working. +/// +public class AuditingMongoRepository : MongoRepository where T : BaseEntity +{ + private readonly RepositoryAudit _audit; + + public AuditingMongoRepository(IMongoDatabase database, IAuditInfoProvider auditInfoProvider, + IAuditTracker tracker) : base(database, auditInfoProvider) + { + _audit = new RepositoryAudit(tracker, LoadMany); + } + + //used before installation, when no database is registered yet + public AuditingMongoRepository(IAuditInfoProvider auditInfoProvider, IAuditTracker tracker) + : base(auditInfoProvider) + { + _audit = new RepositoryAudit(tracker, LoadMany); + } + + private async Task> LoadMany(IReadOnlyCollection ids) + { + return await Collection.Find(Builders.Filter.In(x => x.Id, ids)).ToListAsync(); + } + + public override async Task InsertAsync(T entity) + { + var inserted = await base.InsertAsync(entity); + await _audit.Inserted(entity); + return inserted; + } + + public override async Task UpdateAsync(T entity) + { + await _audit.BeforeWrite(entity); + var updated = await base.UpdateAsync(entity); + _audit.AfterFullUpdate(entity); + return updated; + } + + public override async Task UpdateField(string id, Expression> expression, U value) + { + await _audit.BeforeField(id, expression, nameof(UpdateField)); + await base.UpdateField(id, expression, value); + } + + public override async Task IncField(string id, Expression> expression, U value) + { + await _audit.BeforeField(id, expression, nameof(IncField)); + await base.IncField(id, expression, value); + } + + public override async Task UpdateOneAsync(Expression> filterexpression, + UpdateBuilder updateBuilder) + { + await _audit.BeforeUpdateOne(filterexpression, updateBuilder); + await base.UpdateOneAsync(filterexpression, updateBuilder); + } + + public override async Task UpdateManyAsync(Expression> filterexpression, + UpdateBuilder updateBuilder) + { + _audit.Bulk(nameof(UpdateManyAsync), filterexpression.ToString()); + await base.UpdateManyAsync(filterexpression, updateBuilder); + } + + public override async Task AddToCollectionField(string id, Expression>> field, U value) + { + await _audit.BeforeField(id, field, nameof(AddToCollectionField)); + await base.AddToCollectionField(id, field, value); + } + + public override async Task UpdateCollectionFieldItem(string id, Expression>> field, + Expression> elemFieldMatch, U value) + { + await _audit.BeforeField(id, field, nameof(UpdateCollectionFieldItem)); + await base.UpdateCollectionFieldItem(id, field, elemFieldMatch, value); + } + + public override async Task RemoveCollectionFieldItem(string id, Expression>> field, + Expression> elemFieldMatch) + { + await _audit.BeforeField(id, field, nameof(RemoveCollectionFieldItem)); + await base.RemoveCollectionFieldItem(id, field, elemFieldMatch); + } + + public override async Task DeleteAsync(T entity) + { + await _audit.BeforeWrite(entity); + return await base.DeleteAsync(entity); + } + + public override async Task DeleteManyAsync(Expression> filterExpression) + { + _audit.Bulk(nameof(DeleteManyAsync), filterExpression.ToString()); + await base.DeleteManyAsync(filterExpression); + } + + public override Task ClearAsync() + { + _audit.Bulk(nameof(ClearAsync), "all documents"); + return base.ClearAsync(); + } +} diff --git a/src/Core/Grand.Data/Mongo/MongoAuditEntryWriter.cs b/src/Core/Grand.Data/Mongo/MongoAuditEntryWriter.cs new file mode 100644 index 0000000000..c7ea6b94e5 --- /dev/null +++ b/src/Core/Grand.Data/Mongo/MongoAuditEntryWriter.cs @@ -0,0 +1,16 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using MongoDB.Driver; + +namespace Grand.Data.Mongo; + +/// +/// Resolves the database only when entries are written, so it can be registered before installation +/// +public class MongoAuditEntryWriter(Func database) : IAuditEntryWriter +{ + public Task InsertManyAsync(IReadOnlyCollection entries) + { + return database().GetCollection(nameof(AuditEntry)).InsertManyAsync(entries); + } +} diff --git a/src/Core/Grand.Data/Mongo/MongoRepository.cs b/src/Core/Grand.Data/Mongo/MongoRepository.cs index 5f95393d68..2d426a55ae 100644 --- a/src/Core/Grand.Data/Mongo/MongoRepository.cs +++ b/src/Core/Grand.Data/Mongo/MongoRepository.cs @@ -353,7 +353,7 @@ public virtual async Task DeleteManyAsync(Expression> filterExpres /// /// Clear entities /// - public Task ClearAsync() + public virtual Task ClearAsync() { return Collection.DeleteManyAsync(Builders.Filter.Empty); } diff --git a/src/Core/Grand.Domain/Admin/AdminSiteMap.cs b/src/Core/Grand.Domain/Admin/AdminSiteMap.cs index 72d9beaa32..017ed2e5a0 100644 --- a/src/Core/Grand.Domain/Admin/AdminSiteMap.cs +++ b/src/Core/Grand.Domain/Admin/AdminSiteMap.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Admin; +using Grand.Domain.Audit; + +namespace Grand.Domain.Admin; /// /// Represents an admin menu @@ -14,6 +16,7 @@ public AdminSiteMap() /// /// Gets or sets the system name. /// + [AuditName] public string SystemName { get; set; } /// diff --git a/src/Core/Grand.Domain/Audit/AuditChange.cs b/src/Core/Grand.Domain/Audit/AuditChange.cs new file mode 100644 index 0000000000..de86828043 --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditChange.cs @@ -0,0 +1,32 @@ +namespace Grand.Domain.Audit; + +/// +/// One changed field of an audited entity +/// +public class AuditChange +{ + /// + /// Gets or sets the field path, e.g. Price or TierPrices[id=…].Quantity + /// + public string Path { get; set; } + + /// + /// Gets or sets the value before the change (null when masked or when the field was added) + /// + public string OldValue { get; set; } + + /// + /// Gets or sets the value after the change (null when masked or when the field was removed) + /// + public string NewValue { get; set; } + + /// + /// Gets or sets a value indicating whether the values are hidden because the field is sensitive + /// + public bool Masked { get; set; } + + /// + /// Gets or sets a value indicating whether a value was cut to the stored length + /// + public bool Truncated { get; set; } +} diff --git a/src/Core/Grand.Domain/Audit/AuditEntry.cs b/src/Core/Grand.Domain/Audit/AuditEntry.cs new file mode 100644 index 0000000000..eb1022e312 --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditEntry.cs @@ -0,0 +1,72 @@ +namespace Grand.Domain.Audit; + +/// +/// Represents one record of the staff audit trail. Entries are never updated; only the retention task deletes them. +/// +public class AuditEntry : BaseEntity +{ + public AuditKind Kind { get; set; } + + public AuditSource Source { get; set; } + + /// + /// Gets or sets the staff member who made the change + /// + public string ActorCustomerId { get; set; } + + /// + /// Gets or sets the actor's e-mail at the time of the change + /// + public string ActorEmail { get; set; } + + /// + /// Gets or sets the customer the actor was impersonating, if any + /// + public string ImpersonatedCustomerId { get; set; } + + /// + /// Gets or sets the actor's staff store + /// + public string StoreId { get; set; } + + /// + /// Gets or sets the actor's vendor + /// + public string VendorId { get; set; } + + public string IpAddress { get; set; } + + public string UserAgent { get; set; } + + /// + /// Gets or sets the request's intent as "Area / Controller / Action" + /// + public string Action { get; set; } + + /// + /// Gets or sets the identifier shared by every entry of one request or import + /// + public string OperationId { get; set; } + + public string EntityType { get; set; } + + public string EntityId { get; set; } + + /// + /// Gets or sets a display label of the entity at the time of the change + /// + public string EntityName { get; set; } + + /// + /// Gets or sets the store the changed record belongs to (a setting: empty for all stores); null when the + /// record has no store + /// + public string EntityStoreId { get; set; } + + public IList Changes { get; set; } = new List(); + + /// + /// Gets or sets free text for events and bulk operations + /// + public string Details { get; set; } +} diff --git a/src/Core/Grand.Domain/Audit/AuditKind.cs b/src/Core/Grand.Domain/Audit/AuditKind.cs new file mode 100644 index 0000000000..fe438bef1c --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditKind.cs @@ -0,0 +1,22 @@ +namespace Grand.Domain.Audit; + +/// +/// What an audit entry records +/// +public enum AuditKind +{ + Insert = 0, + Update = 1, + Delete = 2, + Bulk = 3, + SignIn = 10, + SignInFailed = 11, + SignOut = 12, + AccessDenied = 13, + Export = 20, + ImpersonationStart = 30, + ImpersonationEnd = 31, + AuditLogViewed = 40, + AuditLogExported = 41, + Plugin = 50 +} diff --git a/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs b/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs new file mode 100644 index 0000000000..f728458a89 --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs @@ -0,0 +1,8 @@ +namespace Grand.Domain.Audit; + +/// +/// Marks the property that names a record in the audit log (shown in the list, and the only trace of what a +/// deleted record was). Without it, the record's Name property is used. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class AuditNameAttribute : Attribute; diff --git a/src/Core/Grand.Domain/Audit/AuditSource.cs b/src/Core/Grand.Domain/Audit/AuditSource.cs new file mode 100644 index 0000000000..8f5034c5b5 --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditSource.cs @@ -0,0 +1,14 @@ +namespace Grand.Domain.Audit; + +/// +/// Where the audited request came from +/// +public enum AuditSource +{ + Admin = 0, + Store = 1, + Vendor = 2, + Api = 3, + Scim = 4, + Storefront = 5 +} diff --git a/src/Core/Grand.Domain/Blogs/BlogPost.cs b/src/Core/Grand.Domain/Blogs/BlogPost.cs index 2f17decf72..5d9e6547a6 100644 --- a/src/Core/Grand.Domain/Blogs/BlogPost.cs +++ b/src/Core/Grand.Domain/Blogs/BlogPost.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Seo; using Grand.Domain.Stores; @@ -12,6 +13,7 @@ public class BlogPost : BaseEntity, ISlugEntity, IStoreLinkEntity, ITranslationE /// /// Gets or sets the blog post title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductReview.cs b/src/Core/Grand.Domain/Catalog/ProductReview.cs index 34b94f6ea7..879b0a050e 100644 --- a/src/Core/Grand.Domain/Catalog/ProductReview.cs +++ b/src/Core/Grand.Domain/Catalog/ProductReview.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -30,6 +32,7 @@ public class ProductReview : BaseEntity /// /// Gets or sets the title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index c45ef32e1f..71899c3246 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Common; using Grand.Domain.Orders; @@ -36,6 +37,7 @@ public Customer() /// /// Gets or sets the email /// + [AuditName] public string Email { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/CustomerNote.cs b/src/Core/Grand.Domain/Customers/CustomerNote.cs index 675b313abe..a37d3cbdd8 100644 --- a/src/Core/Grand.Domain/Customers/CustomerNote.cs +++ b/src/Core/Grand.Domain/Customers/CustomerNote.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Customers; /// @@ -13,6 +15,7 @@ public class CustomerNote : BaseEntity /// /// Gets or sets the title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs index d657c2c5ce..82742504fb 100644 --- a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs +++ b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Customers; /// @@ -13,6 +15,7 @@ public class ExternalAuthentication : BaseEntity /// /// Gets or sets the external email /// + [AuditName] public string Email { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/UserApi.cs b/src/Core/Grand.Domain/Customers/UserApi.cs index 8f2fa7d1d5..d64e5d6c16 100644 --- a/src/Core/Grand.Domain/Customers/UserApi.cs +++ b/src/Core/Grand.Domain/Customers/UserApi.cs @@ -1,8 +1,11 @@ -namespace Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Customers; [Obsolete("API users with a shared SecretKey are deprecated - configure OidcResourceServer and use access tokens of your identity provider (docs/oidc-resource-server.md)")] public class UserApi : BaseEntity { + [AuditName] public string Email { get; set; } public string Password { get; set; } public string PrivateKey { get; set; } diff --git a/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs b/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs index 62e456f72f..fa0cdc8c6b 100644 --- a/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs +++ b/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs @@ -1,10 +1,13 @@ -namespace Grand.Domain.Discounts; +using Grand.Domain.Audit; + +namespace Grand.Domain.Discounts; public class DiscountCoupon : BaseEntity { /// /// Gets or sets the coupon code /// + [AuditName] public string CouponCode { get; set; } /// diff --git a/src/Core/Grand.Domain/Messages/CampaignHistory.cs b/src/Core/Grand.Domain/Messages/CampaignHistory.cs index e338573bda..684e41116d 100644 --- a/src/Core/Grand.Domain/Messages/CampaignHistory.cs +++ b/src/Core/Grand.Domain/Messages/CampaignHistory.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Messages; +using Grand.Domain.Audit; + +namespace Grand.Domain.Messages; /// /// Represents a campaign history @@ -7,6 +9,7 @@ public class CampaignHistory : BaseEntity { public string CampaignId { get; set; } + [AuditName] public string Email { get; set; } public string CustomerId { get; set; } diff --git a/src/Core/Grand.Domain/Messages/ContactUs.cs b/src/Core/Grand.Domain/Messages/ContactUs.cs index 97c820a60b..5a8713c31f 100644 --- a/src/Core/Grand.Domain/Messages/ContactUs.cs +++ b/src/Core/Grand.Domain/Messages/ContactUs.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Common; +using Grand.Domain.Audit; +using Grand.Domain.Common; namespace Grand.Domain.Messages; @@ -25,6 +26,7 @@ public class ContactUs : BaseEntity /// /// Gets or sets the email /// + [AuditName] public string Email { get; set; } /// diff --git a/src/Core/Grand.Domain/Messages/EmailAccount.cs b/src/Core/Grand.Domain/Messages/EmailAccount.cs index 4eee08453b..ef756fa671 100644 --- a/src/Core/Grand.Domain/Messages/EmailAccount.cs +++ b/src/Core/Grand.Domain/Messages/EmailAccount.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Messages; +using Grand.Domain.Audit; + +namespace Grand.Domain.Messages; /// /// Represents an email account @@ -8,6 +10,7 @@ public class EmailAccount : BaseEntity /// /// Gets or sets an email address /// + [AuditName] public string Email { get; set; } /// diff --git a/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs b/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs index 682f8fc8e5..395b054ffc 100644 --- a/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs +++ b/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs @@ -1,4 +1,5 @@ -using Grand.Domain.History; +using Grand.Domain.Audit; +using Grand.Domain.History; namespace Grand.Domain.Messages; @@ -22,6 +23,7 @@ public class NewsLetterSubscription : BaseEntity, IHistory /// /// Gets or sets the subcriber email /// + [AuditName] public string Email { get; set; } /// diff --git a/src/Core/Grand.Domain/News/NewsItem.cs b/src/Core/Grand.Domain/News/NewsItem.cs index 083bac2fdf..c712b5d29a 100644 --- a/src/Core/Grand.Domain/News/NewsItem.cs +++ b/src/Core/Grand.Domain/News/NewsItem.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; @@ -15,6 +16,7 @@ public class NewsItem : BaseEntity, ISlugEntity, IStoreLinkEntity, ITranslationE /// /// Gets or sets the news title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/GiftVoucher.cs b/src/Core/Grand.Domain/Orders/GiftVoucher.cs index aa3c0a976a..5fde714341 100644 --- a/src/Core/Grand.Domain/Orders/GiftVoucher.cs +++ b/src/Core/Grand.Domain/Orders/GiftVoucher.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Catalog; namespace Grand.Domain.Orders; @@ -32,6 +33,7 @@ public class GiftVoucher : BaseEntity /// /// Gets or sets a gift voucher coupon code /// + [AuditName] public string Code { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs b/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs index e2b98ea56f..5e254916ca 100644 --- a/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs +++ b/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Orders; +using Grand.Domain.Audit; + +namespace Grand.Domain.Orders; /// /// Represents a merchandise return note @@ -13,6 +15,7 @@ public class MerchandiseReturnNote : BaseEntity /// /// Gets or sets the title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/Order.cs b/src/Core/Grand.Domain/Orders/Order.cs index d5a1c57d84..63dca98211 100644 --- a/src/Core/Grand.Domain/Orders/Order.cs +++ b/src/Core/Grand.Domain/Orders/Order.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Payments; @@ -25,6 +26,7 @@ public class Order : BaseEntity /// /// Gets or sets the order identifier /// + [AuditName] public int OrderNumber { get; set; } /// diff --git a/src/Core/Grand.Domain/Pages/Page.cs b/src/Core/Grand.Domain/Pages/Page.cs index bb22146d30..7b90cff1ca 100644 --- a/src/Core/Grand.Domain/Pages/Page.cs +++ b/src/Core/Grand.Domain/Pages/Page.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; @@ -63,6 +64,7 @@ public class Page : BaseEntity, ITranslationEntity, ISlugEntity, IStoreLinkEntit /// /// Gets or sets the title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Domain/Permissions/PermissionAction.cs b/src/Core/Grand.Domain/Permissions/PermissionAction.cs index 492716ea15..fff65a6ef2 100644 --- a/src/Core/Grand.Domain/Permissions/PermissionAction.cs +++ b/src/Core/Grand.Domain/Permissions/PermissionAction.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Permissions; +using Grand.Domain.Audit; + +namespace Grand.Domain.Permissions; /// /// Represents permission for action denied @@ -8,6 +10,7 @@ public class PermissionAction : BaseEntity /// /// Gets or sets the permission system name /// + [AuditName] public string SystemName { get; set; } /// diff --git a/src/Core/Grand.Domain/Permissions/PermissionSystemName.cs b/src/Core/Grand.Domain/Permissions/PermissionSystemName.cs index 705b70f72d..febe81e5e4 100644 --- a/src/Core/Grand.Domain/Permissions/PermissionSystemName.cs +++ b/src/Core/Grand.Domain/Permissions/PermissionSystemName.cs @@ -68,6 +68,7 @@ public static class PermissionSystemName public const string Pictures = "ManagePictures"; public const string HtmlEditor = "HtmlEditor.ManagePictures"; public const string ScheduleTasks = "ManageScheduleTasks"; + public const string AuditLog = "ManageAuditLog"; public const string AllowUseApi = "AllowUseApi"; diff --git a/src/Core/Grand.Domain/Permissions/StandardPermissionSystem.cs b/src/Core/Grand.Domain/Permissions/StandardPermissionSystem.cs index c299ded37f..5d25786034 100644 --- a/src/Core/Grand.Domain/Permissions/StandardPermissionSystem.cs +++ b/src/Core/Grand.Domain/Permissions/StandardPermissionSystem.cs @@ -66,5 +66,14 @@ public static partial class StandardPermission { PermissionActionName.List, PermissionActionName.Edit, PermissionActionName.Preview } }; + public static readonly Permission ManageAuditLog = new() { + Name = "Manage Audit Log", + SystemName = PermissionSystemName.AuditLog, + Area = "Admin area", + Category = CategorySystem, + Actions = new List + { PermissionActionName.List, PermissionActionName.Preview, PermissionActionName.Export, PermissionActionName.Edit } + }; + private static string CategorySystem => "System"; } \ No newline at end of file diff --git a/src/Core/Grand.Domain/PushNotifications/PushMessage.cs b/src/Core/Grand.Domain/PushNotifications/PushMessage.cs index 3c5f7ec8e3..64196b8fff 100644 --- a/src/Core/Grand.Domain/PushNotifications/PushMessage.cs +++ b/src/Core/Grand.Domain/PushNotifications/PushMessage.cs @@ -1,7 +1,10 @@ -namespace Grand.Domain.PushNotifications; +using Grand.Domain.Audit; + +namespace Grand.Domain.PushNotifications; public class PushMessage : BaseEntity { + [AuditName] public string Title { get; set; } public string Text { get; set; } diff --git a/src/Core/Grand.Domain/Vendors/VendorReview.cs b/src/Core/Grand.Domain/Vendors/VendorReview.cs index 1b9f8fad6c..e96cc838fc 100644 --- a/src/Core/Grand.Domain/Vendors/VendorReview.cs +++ b/src/Core/Grand.Domain/Vendors/VendorReview.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Vendors; +using Grand.Domain.Audit; + +namespace Grand.Domain.Vendors; /// /// Represents a vendor review @@ -25,6 +27,7 @@ public class VendorReview : BaseEntity /// /// Gets or sets the title /// + [AuditName] public string Title { get; set; } /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs new file mode 100644 index 0000000000..e8d1b1a709 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs @@ -0,0 +1,83 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using System.Text.RegularExpressions; + +namespace Grand.Infrastructure.Audit; + +/// +/// Compares two snapshots of an entity (see ) and lists the paths whose values differ +/// +public static partial class AuditDiff +{ + public const int MaxValueLength = 1024; + + [GeneratedRegex("password|salt|hash|secret|api_?key|token|privatekey", RegexOptions.IgnoreCase)] + private static partial Regex SensitiveName(); + + /// + /// Whether a field, path or argument name looks like it holds a secret, whose value is never stored + /// + public static bool IsSensitive(string name) + { + return SensitiveName().IsMatch(name); + } + + /// + /// Lists the changed fields. is null for an insert, + /// is null for a delete (which lists nothing). + /// + public static IList Compare(string entityType, IDictionary before, + IDictionary after, ISet ignoredPaths) + { + var changes = new List(); + if (after == null) + return changes; + + before ??= new Dictionary(); + foreach (var path in after.Keys.Concat(before.Keys.Where(k => !after.ContainsKey(k)))) + { + //an empty value compares like a missing field, so an insert lists only what was set + var oldValue = ValueOf(before, path); + var newValue = ValueOf(after, path); + if ((oldValue == newValue && oldValue != AuditSnapshot.Binary) || ignoredPaths.Any(p => IsUnder(path, p))) + continue; + + //settings JSON that does not parse stays one opaque field and may hold secrets + var masked = IsSensitive(path) || (entityType == "Setting" && path == AuditSnapshot.SettingMetadata); + changes.Add(masked + ? new AuditChange { Path = path, Masked = true } + : Change(path, oldValue, newValue)); + } + + return changes; + } + + private static string ValueOf(IDictionary snapshot, string path) + { + return snapshot.TryGetValue(path, out var value) && !string.IsNullOrEmpty(value) ? value : null; + } + + private static bool IsUnder(string path, string parent) + { + return path == parent + || path.StartsWith(parent + ".", StringComparison.Ordinal) + || path.StartsWith(parent + "[", StringComparison.Ordinal); + } + + private static AuditChange Change(string path, string oldValue, string newValue) + { + var change = new AuditChange { + Path = path, + OldValue = Truncate(oldValue, out var oldTruncated), + NewValue = Truncate(newValue, out var newTruncated) + }; + change.Truncated = oldTruncated || newTruncated; + return change; + } + + private static string Truncate(string value, out bool truncated) + { + truncated = value is { Length: > MaxValueLength }; + return truncated ? value[..MaxValueLength] : value; + } +} diff --git a/src/Core/Grand.Infrastructure/Audit/AuditEntryCreated.cs b/src/Core/Grand.Infrastructure/Audit/AuditEntryCreated.cs new file mode 100644 index 0000000000..c134dd2380 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditEntryCreated.cs @@ -0,0 +1,12 @@ +using Grand.Domain.Audit; +using Grand.Mediator; + +namespace Grand.Infrastructure.Audit; + +/// +/// Published after an audit entry is stored, so a plugin can forward it (e.g. to a SIEM) +/// +public class AuditEntryCreated(AuditEntry entry) : INotification +{ + public AuditEntry Entry { get; } = entry; +} diff --git a/src/Core/Grand.Infrastructure/Audit/AuditSourceAttribute.cs b/src/Core/Grand.Infrastructure/Audit/AuditSourceAttribute.cs new file mode 100644 index 0000000000..4685f5640f --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditSourceAttribute.cs @@ -0,0 +1,12 @@ +using Grand.Domain.Audit; + +namespace Grand.Infrastructure.Audit; + +/// +/// Marks controllers outside the panel areas whose staff requests are audited (API, SCIM) +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)] +public class AuditSourceAttribute(AuditSource source) : Attribute +{ + public AuditSource Source { get; } = source; +} diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs new file mode 100644 index 0000000000..91265d7c77 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -0,0 +1,340 @@ +using Grand.Data.Audit; +using Grand.Domain; +using Grand.Domain.Audit; +using Grand.Mediator; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Reflection; + +namespace Grand.Infrastructure.Audit; + +/// +/// Scoped per request: snapshots each entity once before its first write, reads the "after" state in batches +/// and writes the entries in one insert per batch. Does nothing until activated for a staff request. +/// +public class AuditTracker(IAuditEntryWriter writer, IMediator mediator, ILogger logger) + : IAuditTracker +{ + public const int BatchSize = 200; + + private const string InsertedAndDeleted = "inserted and deleted in the same operation"; + + private static readonly HashSet ExcludedTypes = [nameof(AuditEntry), "QueuedEmail"]; + + //bookkeeping written on every save or page view; Ticks is the product's concurrency stamp + private static readonly HashSet IgnoredPaths = [ + "UpdatedOnUtc", "UpdatedBy", "CreatedOnUtc", "CreatedBy", "Ticks", + "LastActivityDateUtc", "LastIpAddress", "LastVisitedPage" + ]; + + //the property that names a record of each type: [AuditName], else Name + private static readonly ConcurrentDictionary NameProperties = new(); + + private readonly List _bulk = []; + + private readonly Dictionary, Task>>>> + _loaders = new(); + + private readonly object _lock = new(); + private readonly Dictionary<(Type Type, string Id), Pending> _pending = new(); + + //set in the flow of the AuditEntryCreated handlers: what a handler writes (e.g. a forwarding outbox) is not a + //staff change, and recording it would publish again + private readonly AsyncLocal _publishing = new(); + private AuditActor _actor; + + public bool IsActive => _actor != null && !_publishing.Value; + + public void Activate(AuditActor actor) + { + _actor = actor; + } + + public bool IsAudited(Type entityType) + { + return !ExcludedTypes.Contains(entityType.Name); + } + + public async Task BeforeWriteAsync(string id, bool isInsert, + Func, Task>> loadMany, IReadOnlyCollection writtenPaths = null) + where T : BaseEntity + { + if (!IsActive || string.IsNullOrEmpty(id)) + return; + if (writtenPaths is { Count: > 0 } && writtenPaths.All(IgnoredPaths.Contains)) + return; + + var key = (typeof(T), id); + bool flush; + lock (_lock) + { + if (_pending.TryGetValue(key, out var existing)) + { + existing.Merge(writtenPaths); + return; + } + + flush = _pending.Count >= BatchSize; + } + + if (flush) + await FlushAsync(); + + IDictionary before = null; + if (!isInsert) + try + { + var stored = (await loadMany([id])).FirstOrDefault(); + before = stored == null ? null : AuditSnapshot.Take(stored); + } + catch (Exception ex) + { + logger.LogError(ex, "Audit could not read {EntityType} {EntityId} before operation {OperationId}", + typeof(T).Name, id, _actor.OperationId); + return; + } + + lock (_lock) + { + _loaders[typeof(T)] = async ids => + (await loadMany(ids)).ToDictionary(e => e.Id, e => AuditSnapshot.Take(e)); + + if (_pending.TryGetValue(key, out var existing)) + existing.Merge(writtenPaths); + else + _pending[key] = new Pending(before, writtenPaths); + } + } + + public void AfterFullUpdate(T entity) where T : BaseEntity + { + if (!IsActive || entity == null) + return; + + var key = (typeof(T), entity.Id); + lock (_lock) + { + if (!_pending.ContainsKey(key)) + return; + } + + //runs after the write: a failure here must not turn a stored change into an error + try + { + var after = AuditSnapshot.Take(entity); + lock (_lock) + { + if (_pending.TryGetValue(key, out var pending)) + pending.AfterHint = after; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Audit could not keep {EntityType} {EntityId} after operation {OperationId}", + typeof(T).Name, entity.Id, _actor.OperationId); + } + } + + public void RecordBulk(string operation, string filter) where T : BaseEntity + { + if (!IsActive) + return; + + lock (_lock) + { + _bulk.Add(new AuditEntry { + Kind = AuditKind.Bulk, + EntityType = typeof(T).Name, + Details = $"{operation}: {filter}" + }); + } + } + + public async Task CompleteAsync() + { + await FlushAsync(); + //anything written after the request scope would wait in _pending for a flush that never comes + _actor = null; + } + + public async Task FlushAsync() + { + if (_actor == null) + return; + + List> pending; + List bulk; + Dictionary, Task>>>> loaders; + lock (_lock) + { + if (_pending.Count == 0 && _bulk.Count == 0) + return; + + pending = _pending.ToList(); + bulk = _bulk.ToList(); + loaders = new Dictionary, Task>>>>(_loaders); + _pending.Clear(); + _bulk.Clear(); + } + + List entries; + try + { + entries = await BuildEntries(pending, loaders); + entries.AddRange(bulk); + if (entries.Count == 0) + return; + + var now = DateTime.UtcNow; + foreach (var entry in entries) + { + entry.CreatedOnUtc = now; + _actor.Stamp(entry); + } + + await writer.InsertManyAsync(entries); + } + catch (Exception ex) + { + logger.LogError(ex, "Audit entries of operation {OperationId} were not stored: {Entities}", + _actor.OperationId, string.Join(", ", pending.Select(p => $"{p.Key.Type.Name}:{p.Key.Id}"))); + return; + } + + _publishing.Value = true; + foreach (var entry in entries) + try + { + await mediator.Publish(new AuditEntryCreated(entry)); + } + catch (Exception ex) + { + logger.LogError(ex, "A handler of AuditEntryCreated failed for entry {EntryId}", entry.Id); + } + } + + private static async Task> BuildEntries( + List> pending, + Dictionary, Task>>>> loaders) + { + var entries = new List(); + foreach (var group in pending.GroupBy(p => p.Key.Type)) + { + var toRead = group.Where(p => p.Value.AfterHint == null).Select(p => p.Key.Id).ToList(); + var read = toRead.Count > 0 + ? await loaders[group.Key](toRead) + : new Dictionary>(); + + foreach (var item in group) + { + var after = item.Value.AfterHint ?? (read.TryGetValue(item.Key.Id, out var stored) ? stored : null); + AuditEntry entry; + try + { + entry = Build(group.Key, item.Key.Id, item.Value, after); + } + catch (Exception) + { + entry = new AuditEntry { + Kind = after == null ? AuditKind.Delete : AuditKind.Update, + EntityType = group.Key.Name, + EntityId = item.Key.Id, + Details = "changes could not be computed" + }; + } + + if (entry != null) + entries.Add(entry); + } + } + + return entries; + } + + private static AuditEntry Build(Type type, string entityId, Pending pending, + IDictionary after) + { + var entityType = type.Name; + var before = pending.Before; + var entry = new AuditEntry { + EntityType = entityType, + EntityId = entityId, + EntityName = NameOf(type, after ?? before), + EntityStoreId = StoreIdOf(after ?? before) + }; + + if (after == null) + { + entry.Kind = AuditKind.Delete; + if (before == null) + entry.Details = InsertedAndDeleted; + return entry; + } + + var changes = AuditDiff.Compare(entityType, before, after, IgnoredPaths); + if (before == null) + { + entry.Kind = AuditKind.Insert; + entry.Changes = changes; + return entry; + } + + if (pending.Paths != null) + changes = changes.Where(c => pending.Paths.Any(p => Touches(c.Path, p))).ToList(); + if (changes.Count == 0) + return null; + + entry.Kind = AuditKind.Update; + entry.Changes = changes; + return entry; + } + + private static string NameOf(Type type, IDictionary snapshot) + { + var property = NameProperties.GetOrAdd(type, t => + t.GetProperties().FirstOrDefault(p => p.IsDefined(typeof(AuditNameAttribute)))?.Name ?? "Name"); + return snapshot != null && snapshot.TryGetValue(property, out var name) && name != "" ? name : null; + } + + //"" is a record of all stores, null one without a store + private static string StoreIdOf(IDictionary snapshot) + { + return snapshot != null && snapshot.TryGetValue("StoreId", out var storeId) ? storeId : null; + } + + private static bool Touches(string changePath, string writtenPath) + { + return changePath == writtenPath + || changePath.StartsWith(writtenPath + ".", StringComparison.Ordinal) + || changePath.StartsWith(writtenPath + "[", StringComparison.Ordinal); + } + + private sealed class Pending(IDictionary before, IReadOnlyCollection paths) + { + public IDictionary Before { get; } = before; + + /// + /// The paths written in this scope; null means every field + /// + public HashSet Paths { get; private set; } = paths == null ? null : [..paths]; + + /// + /// The state a full update wrote, so the flush does not re-read it + /// + public IDictionary AfterHint { get; set; } + + public void Merge(IReadOnlyCollection writtenPaths) + { + //any later write changes the stored document again, so the kept "after" is stale; a full update + //sets it again right after its write + AfterHint = null; + if (writtenPaths == null) + { + Paths = null; + return; + } + + Paths?.UnionWith(writtenPaths); + } + } +} diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs index e83df6ba9e..d422f6e291 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs @@ -231,4 +231,13 @@ public static partial class CacheKey public static string WIDGET_MODEL_KEY => "Grand.pres.widget-{0}-{1}-{2}"; #endregion + + #region Audit log + + /// + /// Key for the entity types present in the audit log + /// + public static string AUDIT_ENTITY_TYPES_KEY => "Grand.audit.entitytypes"; + + #endregion } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/Configuration/AuditConfig.cs b/src/Core/Grand.Infrastructure/Configuration/AuditConfig.cs new file mode 100644 index 0000000000..cf93b2f7eb --- /dev/null +++ b/src/Core/Grand.Infrastructure/Configuration/AuditConfig.cs @@ -0,0 +1,19 @@ +namespace Grand.Infrastructure.Configuration; + +/// +/// Represents the audit trail config (bound from the "Audit" section of appsettings.json). It is set by whoever +/// runs the installation, not in the admin panel, so a staff member cannot shorten or switch off what the trail +/// keeps. +/// +public class AuditConfig +{ + /// + /// Days an entry is kept before the daily retention task deletes it; 0 or less keeps entries forever + /// + public int RetentionDays { get; set; } = 365; + + /// + /// Records staff sign-ins, failed sign-ins and sign-outs + /// + public bool LogStaffSignIns { get; set; } = true; +} diff --git a/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs b/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs index d94c37a71f..ed25afb4e8 100644 --- a/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs +++ b/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs @@ -1,6 +1,8 @@ using Grand.Data; +using Grand.Data.Audit; using Grand.Data.LiteDb; using Grand.Data.Mongo; +using Grand.Infrastructure.Audit; using Grand.Infrastructure.Configuration; using LiteDB; using Microsoft.AspNetCore.Builder; @@ -76,8 +78,10 @@ private void RegisterDataLayer(IServiceCollection serviceCollection, IConfigurat serviceCollection.AddScoped(); //store files context - grid fs serviceCollection.AddScoped(); - //Mongo Repository - serviceCollection.AddScoped(typeof(IRepository<>), typeof(MongoRepository<>)); + //Mongo Repository - reports staff writes to the audit tracker + serviceCollection.AddScoped(typeof(IRepository<>), typeof(AuditingMongoRepository<>)); + serviceCollection.AddScoped(sp => + new MongoAuditEntryWriter(sp.GetRequiredService)); } else { @@ -85,8 +89,12 @@ private void RegisterDataLayer(IServiceCollection serviceCollection, IConfigurat serviceCollection.AddScoped(); //store files context - grid fs serviceCollection.AddScoped(); - //Mongo Repository - serviceCollection.AddScoped(typeof(IRepository<>), typeof(LiteDBRepository<>)); + //LiteDB Repository - reports staff writes to the audit tracker + serviceCollection.AddScoped(typeof(IRepository<>), typeof(AuditingLiteDBRepository<>)); + serviceCollection.AddScoped(sp => + new LiteDBAuditEntryWriter(sp.GetRequiredService)); } + + serviceCollection.AddScoped(); } } \ No newline at end of file diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs index bed3e03a40..755ca8ce0c 100644 --- a/src/Core/Grand.Infrastructure/StartupBase.cs +++ b/src/Core/Grand.Infrastructure/StartupBase.cs @@ -224,6 +224,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu services.StartupConfig(configuration.GetSection("OidcResourceServer")); services.StartupConfig(configuration.GetSection("OidcLogin")); services.StartupConfig(configuration.GetSection("Scim")); + services.StartupConfig(configuration.GetSection("Audit")); services.StartupConfig(configuration.GetSection("Database")); services.StartupConfig(configuration.GetSection("Amazon")); services.StartupConfig(configuration.GetSection("Azure")); diff --git a/src/Modules/Grand.Module.Api/Controllers/BaseApiController.cs b/src/Modules/Grand.Module.Api/Controllers/BaseApiController.cs index c46ffbe996..d333d360e8 100644 --- a/src/Modules/Grand.Module.Api/Controllers/BaseApiController.cs +++ b/src/Modules/Grand.Module.Api/Controllers/BaseApiController.cs @@ -1,5 +1,7 @@ using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Infrastructure; +using Grand.Infrastructure.Audit; using Grand.Module.Api.Attributes; using Grand.Module.Api.Constants; using Grand.Module.Api.Filters; @@ -16,6 +18,7 @@ namespace Grand.Module.Api.Controllers; [Route($"{Configurations.RestRoutePrefix}/[controller]")] [ApiExplorerSettings(IgnoreApi = false, GroupName = "v1")] [Produces("application/json")] +[AuditSource(AuditSource.Api)] public abstract class BaseApiController : ControllerBase { /// diff --git a/src/Modules/Grand.Module.Installer/Extensions/PermissionExtensions.cs b/src/Modules/Grand.Module.Installer/Extensions/PermissionExtensions.cs index dee1cb5ef6..297cafbd89 100644 --- a/src/Modules/Grand.Module.Installer/Extensions/PermissionExtensions.cs +++ b/src/Modules/Grand.Module.Installer/Extensions/PermissionExtensions.cs @@ -72,6 +72,7 @@ public static IEnumerable Permissions() StandardPermission.ManagePictures, StandardPermission.HtmlEditorManagePictures, StandardPermission.ManageScheduleTasks, + StandardPermission.ManageAuditLog, StandardPermission.DisplayPrices, StandardPermission.EnableShoppingCart, StandardPermission.EnableWishlist, @@ -153,6 +154,7 @@ public static IEnumerable DefaultPermissions() StandardPermission.ManagePictures, StandardPermission.HtmlEditorManagePictures, StandardPermission.ManageScheduleTasks, + StandardPermission.ManageAuditLog, StandardPermission.DisplayPrices, StandardPermission.EnableShoppingCart, StandardPermission.EnableWishlist, diff --git a/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs index 5837de4c14..6dce20f233 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataScheduleTasks.cs @@ -57,6 +57,12 @@ protected virtual Task InstallScheduleTasks() Enabled = true, StopOnError = false, TimeInterval = 1440 + }, + new ScheduleTask { + ScheduleTaskName = "Audit log retention", + Enabled = true, + StopOnError = false, + TimeInterval = 1440 } }; tasks.ForEach(x => _scheduleTaskRepository.Insert(x)); diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 64a89f9fea..f540b14958 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -1,6 +1,8 @@ using Grand.Business.Core.Interfaces.Common.Security; using Grand.Data; +using Grand.Data.Audit; using Grand.Domain; +using Grand.Domain.Audit; using Grand.Domain.Admin; using Grand.Domain.Affiliates; using Grand.Domain.Blogs; @@ -125,6 +127,7 @@ public InstallationService( IRepository permissionAction, IRepository externalAuthenticationRepository, IRepository oidcSessionRepository, + IRepository auditEntryRepository, IRepository merchandiseReturnReasonRepository, IRepository merchandiseReturnActionRepository, IRepository contactUsRepository, @@ -223,6 +226,7 @@ public InstallationService( _vendorRepository = vendorRepository; _externalAuthenticationRepository = externalAuthenticationRepository; _oidcSessionRepository = oidcSessionRepository; + _auditEntryRepository = auditEntryRepository; _discountusageRepository = discountusageRepository; _merchandiseReturnReasonRepository = merchandiseReturnReasonRepository; _contactUsRepository = contactUsRepository; @@ -391,6 +395,7 @@ public virtual async Task InstallData(string httpscheme, HostString host, string private readonly IRepository _permissionAction; private readonly IRepository _externalAuthenticationRepository; private readonly IRepository _oidcSessionRepository; + private readonly IRepository _auditEntryRepository; private readonly IRepository _merchandiseReturnReasonRepository; private readonly IRepository _merchandiseReturnActionRepository; private readonly IRepository _contactUsRepository; @@ -955,6 +960,9 @@ await dbContext.CreateIndex(_orderApprovalRepository, await dbContext.CreateIndex(_orderApprovalRepository, OrderBuilder.Create().Ascending(x => x.OrderId), "OrderId"); + //audit log: the list by date, the history popup by entity, the actor filter and the operation link + await AuditIndexes.Create(dbContext, _auditEntryRepository); + //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, OrderBuilder.Create().Ascending(x => x.ReturnNumber), "ReturnNumber", true); diff --git a/src/Modules/Grand.Module.Installer/Utilities/PermissionSystemName.cs b/src/Modules/Grand.Module.Installer/Utilities/PermissionSystemName.cs index b2b910a887..25fde99885 100644 --- a/src/Modules/Grand.Module.Installer/Utilities/PermissionSystemName.cs +++ b/src/Modules/Grand.Module.Installer/Utilities/PermissionSystemName.cs @@ -67,6 +67,7 @@ public static class PermissionSystemName public const string Pictures = "ManagePictures"; public const string HtmlEditor = "HtmlEditor.ManagePictures"; public const string ScheduleTasks = "ManageScheduleTasks"; + public const string AuditLog = "ManageAuditLog"; public const string AllowUseApi = "AllowUseApi"; diff --git a/src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs b/src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs index 2de67ae29f..39bc978fb6 100644 --- a/src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs +++ b/src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs @@ -1048,7 +1048,8 @@ public static class StandardAdminSiteMap ResourceName = "Admin.System", PermissionNames = new List { PermissionSystemName.MessageQueue, PermissionSystemName.MessageContactForm, - PermissionSystemName.Maintenance, PermissionSystemName.ScheduleTasks, PermissionSystemName.System + PermissionSystemName.Maintenance, PermissionSystemName.ScheduleTasks, PermissionSystemName.System, + PermissionSystemName.AuditLog }, IconClass = "bi bi-info-circle", DisplayOrder = 10, @@ -1098,6 +1099,15 @@ public static class StandardAdminSiteMap DisplayOrder = 5, IconClass = "bi bi-record-circle" }, + new() { + SystemName = "Audit log", + ResourceName = "Admin.System.AuditLog", + PermissionNames = new List { PermissionSystemName.AuditLog }, + ControllerName = "AuditLog", + ActionName = "List", + DisplayOrder = 6, + IconClass = "bi bi-record-circle" + }, new() { SystemName = "Developer tools", ResourceName = "Admin.System.DeveloperTools", diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationAuditLog.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationAuditLog.cs new file mode 100644 index 0000000000..2d8169f02b --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationAuditLog.cs @@ -0,0 +1,121 @@ +using Grand.Data; +using Grand.Data.Audit; +using Grand.Domain.Admin; +using Grand.Domain.Audit; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Domain.Tasks; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationAuditLog : IMigration +{ + private const string TaskName = "Audit log retention"; + + public int Priority => 0; + public DbVersion Version => new(2, 5); + public Guid Identity => new("8d2f4b6a-1e3c-4f7a-9b0d-6c5e2a8f1d47"); + public string Name => "Audit log: indexes, retention task, permission and admin menu"; + + public async Task UpgradeProcess(IServiceProvider serviceProvider) + { + var auditRepository = serviceProvider.GetRequiredService>(); + var taskRepository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + var success = true; + + try + { + await AuditIndexes.Create(databaseContext, auditRepository); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - AuditLog indexes (2.5)"); + success = false; + } + + try + { + if (!await taskRepository.AnyAsync(taskRepository.Table.Where(x => x.ScheduleTaskName == TaskName))) + await taskRepository.InsertAsync(new ScheduleTask { + ScheduleTaskName = TaskName, Enabled = true, StopOnError = false, TimeInterval = 1440 + }); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - AuditLog retention task (2.5)"); + success = false; + } + + try + { + await AddPermission(serviceProvider); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - AuditLog permission (2.5)"); + success = false; + } + + try + { + await AddSiteMap(serviceProvider); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - AuditLog admin menu (2.5)"); + success = false; + } + + return success; + } + + private static async Task AddPermission(IServiceProvider serviceProvider) + { + var permissionRepository = serviceProvider.GetRequiredService>(); + var groupRepository = serviceProvider.GetRequiredService>(); + + if (await permissionRepository.AnyAsync( + permissionRepository.Table.Where(x => x.SystemName == PermissionSystemName.AuditLog))) + return; + + var permission = new Permission { + Name = StandardPermission.ManageAuditLog.Name, + SystemName = StandardPermission.ManageAuditLog.SystemName, + Area = StandardPermission.ManageAuditLog.Area, + Category = StandardPermission.ManageAuditLog.Category, + Actions = StandardPermission.ManageAuditLog.Actions.ToList() + }; + var administrators = await groupRepository.FirstOrDefaultAsync( + groupRepository.Table.Where(x => x.SystemName == SystemCustomerGroupNames.Administrators)); + if (administrators != null) + permission.CustomerGroups.Add(administrators.Id); + + await permissionRepository.InsertAsync(permission); + } + + private static async Task AddSiteMap(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + var system = await repository.FirstOrDefaultAsync(repository.Table.Where(x => x.SystemName == "System")); + if (system == null || system.ChildNodes.Any(x => x.SystemName == "Audit log")) + return; + + system.ChildNodes.Add(new AdminSiteMap { + SystemName = "Audit log", + ResourceName = "Admin.System.AuditLog", + PermissionNames = new List { PermissionSystemName.AuditLog }, + ControllerName = "AuditLog", + ActionName = "List", + DisplayOrder = 6, + IconClass = "bi bi-record-circle" + }); + if (!system.PermissionNames.Contains(PermissionSystemName.AuditLog)) + system.PermissionNames.Add(PermissionSystemName.AuditLog); + await repository.UpdateAsync(system); + } +} diff --git a/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs new file mode 100644 index 0000000000..a908bdd01d --- /dev/null +++ b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs @@ -0,0 +1,20 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.System.ScheduleTasks; +using Grand.Infrastructure.Configuration; + +namespace Grand.Module.ScheduledTasks.BackgroundServices; + +/// +/// Deletes audit entries older than the retention period - the only way entries leave the log +/// +public class AuditLogRetentionScheduleTask(IAuditService auditService, AuditConfig auditConfig) : IScheduleTask +{ + public async Task Execute(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (auditConfig.RetentionDays <= 0) + return; + + await auditService.DeleteOlderThan(DateTime.UtcNow.AddDays(-auditConfig.RetentionDays)); + } +} diff --git a/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs b/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs index 894d5b4dcc..57c55013c2 100644 --- a/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs +++ b/src/Modules/Grand.Module.ScheduledTasks/Startup/StartupApplication.cs @@ -31,6 +31,7 @@ private static void RegisterTask(IServiceCollection serviceCollection) serviceCollection.AddKeyedScoped("Generate sitemap XML file"); serviceCollection.AddKeyedScoped("Delete guests"); serviceCollection.AddKeyedScoped("Delete OpenID Connect sessions"); + serviceCollection.AddKeyedScoped("Audit log retention"); serviceCollection.AddKeyedScoped("Update currency exchange rates"); serviceCollection.AddKeyedScoped("End of the auctions"); serviceCollection.AddKeyedScoped("Cancel unpaid and pending orders"); diff --git a/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs b/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs index d10abc0f25..58f0c088dc 100644 --- a/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs +++ b/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs @@ -1,4 +1,6 @@ using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; using Grand.Infrastructure.Configuration; using Grand.Module.Scim.Infrastructure; using Microsoft.AspNetCore.Http; @@ -15,6 +17,7 @@ namespace Grand.Module.Scim.Controllers; [ApiExplorerSettings(IgnoreApi = true)] [RequestSizeLimit(MaxBodyBytes)] [Route("scim/v2")] +[AuditSource(AuditSource.Scim)] public abstract class ScimControllerBase : ControllerBase { //a SCIM user or group, or a patch of one; a large group membership patch stays far below diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs new file mode 100644 index 0000000000..954327d5f2 --- /dev/null +++ b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs @@ -0,0 +1,191 @@ +using Grand.Business.Common.Services.Audit; +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Data.Audit; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Tests.Caching; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Common.Tests.Services.Audit; + +[TestClass] +public class AuditServiceTests +{ + private MongoDBRepositoryTest _repository; + private Mock _writer; + private Mock _mediator; + private AuditService _service; + + [TestInitialize] + public async Task Init() + { + _repository = new MongoDBRepositoryTest(); + await _repository.ClearAsync(); + _writer = new Mock(); + _mediator = new Mock(); + _service = new AuditService(_repository, _writer.Object, _mediator.Object, + new MemoryCacheBase(MemoryCacheTest.Get(), _mediator.Object, new CacheConfig { DefaultCacheTimeMinutes = 1 })); + } + + private async Task Add(string entityType, string entityId, DateTime createdOnUtc, + string email = "anna@shop.com") + { + var entry = new AuditEntry { + Kind = AuditKind.Update, EntityType = entityType, EntityId = entityId, ActorEmail = email + }; + await _repository.InsertAsync(entry); + entry.CreatedOnUtc = createdOnUtc; + await _repository.UpdateAsync(entry); + return entry; + } + + [TestMethod] + public async Task GetEntries_FiltersByEntityTypeAndId_NewestFirst() + { + var older = await Add("Product", "p1", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc)); + var newer = await Add("Product", "p1", new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc)); + await Add("Product", "p2", new DateTime(2026, 10, 3, 0, 0, 0, DateTimeKind.Utc)); + await Add("Category", "p1", new DateTime(2026, 10, 3, 0, 0, 0, DateTimeKind.Utc)); + + var result = await _service.GetEntries(new AuditEntrySearch { EntityType = "Product", EntityId = "p1" }); + + CollectionAssert.AreEqual(new[] { newer.Id, older.Id }, result.Select(x => x.Id).ToArray()); + } + + [TestMethod] + public async Task GetEntries_FiltersByEntityNames() + { + var catalog = await Add("Setting", "s1", DateTime.UtcNow); + catalog.EntityName = "catalogsettings"; + await _repository.UpdateAsync(catalog); + var order = await Add("Setting", "s2", DateTime.UtcNow); + order.EntityName = "ordersettings"; + await _repository.UpdateAsync(order); + + var result = await _service.GetEntries(new AuditEntrySearch { + EntityType = "Setting", EntityNames = ["catalogsettings", "mediasettings"] + }); + + Assert.AreEqual(catalog.Id, result.Single().Id); + } + + [TestMethod] + public async Task GetEntries_ActorEmailContainsCaseInsensitive() + { + var anna = await Add("Product", "p1", DateTime.UtcNow, "Anna@Shop.com"); + await Add("Product", "p1", DateTime.UtcNow, "marek@shop.com"); + + var result = await _service.GetEntries(new AuditEntrySearch { ActorEmail = "anna@" }); + + Assert.AreEqual(anna.Id, result.Single().Id); + } + + [TestMethod] + public async Task GetEntries_DateRange() + { + await Add("Product", "p1", new DateTime(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc)); + var inside = await Add("Product", "p1", new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc)); + await Add("Product", "p1", new DateTime(2026, 10, 20, 0, 0, 0, DateTimeKind.Utc)); + + var result = await _service.GetEntries(new AuditEntrySearch { + FromUtc = new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), + ToUtc = new DateTime(2026, 10, 10, 0, 0, 0, DateTimeKind.Utc) + }); + + Assert.AreEqual(inside.Id, result.Single().Id); + } + + [TestMethod] + public async Task StreamEntries_YieldsAllAcrossPages() + { + for (var i = 0; i < 1005; i++) + await _repository.InsertAsync(new AuditEntry { EntityType = "Product", CreatedOnUtc = DateTime.UtcNow }); + + var count = 0; + await foreach (var _ in _service.StreamEntries(new AuditEntrySearch(), CancellationToken.None)) + count++; + + Assert.AreEqual(1005, count); + } + + [TestMethod] + public async Task StreamEntries_SameTimeAcrossPages_EachEntryOnce() + { + //one flush stamps all its entries with the same time + var time = new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + for (var i = 0; i < 2100; i++) + await _repository.InsertAsync(new AuditEntry { EntityType = "Product", CreatedOnUtc = i < 1500 ? time : time.AddMinutes(-1) }); + + var ids = new List(); + await foreach (var entry in _service.StreamEntries(new AuditEntrySearch(), CancellationToken.None)) + ids.Add(entry.Id); + + Assert.AreEqual(2100, ids.Count); + Assert.AreEqual(2100, ids.Distinct().Count()); + } + + [TestMethod] + public async Task GetEntries_Store_MatchesActorStoreOrRecordStore() + { + var byStoreStaff = await Add("Product", "p1", DateTime.UtcNow); + byStoreStaff.StoreId = "s2"; + await _repository.UpdateAsync(byStoreStaff); + var adminOnStoreRecord = await Add("Product", "p2", DateTime.UtcNow); + adminOnStoreRecord.EntityStoreId = "s2"; + await _repository.UpdateAsync(adminOnStoreRecord); + await Add("Product", "p3", DateTime.UtcNow); + + var result = await _service.GetEntries(new AuditEntrySearch { StoreId = "s2" }); + + CollectionAssert.AreEquivalent(new[] { byStoreStaff.Id, adminOnStoreRecord.Id }, + result.Select(x => x.Id).ToArray()); + } + + [TestMethod] + public async Task GetEntityTypes_DistinctSorted() + { + await Add("Product", "1", DateTime.UtcNow); + await Add("Category", "1", DateTime.UtcNow); + await Add("Product", "2", DateTime.UtcNow); + + CollectionAssert.AreEqual(new[] { "Category", "Product" }, (await _service.GetEntityTypes()).ToArray()); + } + + [TestMethod] + public async Task DeleteOlderThan_RemovesOnlyOlder() + { + await Add("Product", "old", new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var kept = await Add("Product", "new", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc)); + + var deleted = await _service.DeleteOlderThan(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.AreEqual(1, deleted); + Assert.AreEqual(kept.Id, _repository.Table.Single().Id); + } + + [TestMethod] + public async Task InsertEvent_WritesAndPublishes() + { + var entry = new AuditEntry { Kind = AuditKind.SignIn }; + + await _service.InsertEvent(entry); + + _writer.Verify(w => w.InsertManyAsync(It.Is>(e => e.Single() == entry))); + _mediator.Verify(m => m.Publish(It.Is(n => n.Entry == entry), It.IsAny())); + Assert.AreNotEqual(default, entry.CreatedOnUtc); + } + + [TestMethod] + public void Service_HasNoUpdateOrDeleteById() + { + var names = typeof(IAuditService).GetMethods().Select(m => m.Name).ToList(); + + Assert.IsFalse(names.Any(n => n.StartsWith("Update"))); + CollectionAssert.AreEqual(new[] { "DeleteOlderThan" }, names.Where(n => n.StartsWith("Delete")).ToArray()); + } +} diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs index 5eb36f462e..939c24f12a 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs @@ -20,4 +20,25 @@ public void StoreManager_DefaultPermissions_IncludeManageCustomers() Assert.IsNotNull(storeManager); CollectionAssert.Contains(storeManager.Permissions.ToList(), StandardPermission.ManageCustomers); } + + [TestMethod] + public void GetPermissions_ContainsManageAuditLog() + { + var permission = _provider.GetPermissions().Single(p => p.SystemName == PermissionSystemName.AuditLog); + + Assert.AreEqual("Admin area", permission.Area); + CollectionAssert.AreEquivalent( + new[] { PermissionActionName.List, PermissionActionName.Preview, PermissionActionName.Export, PermissionActionName.Edit }, + permission.Actions.ToArray()); + } + + [TestMethod] + public void GetDefaultPermissions_OnlyAdministratorsHaveManageAuditLog() + { + var holders = _provider.GetDefaultPermissions() + .Where(d => d.Permissions.Any(p => p.SystemName == PermissionSystemName.AuditLog)) + .Select(d => d.CustomerGroupSystemName).ToList(); + + CollectionAssert.AreEqual(new[] { SystemCustomerGroupNames.Administrators }, holders); + } } diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs new file mode 100644 index 0000000000..c30de6e571 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs @@ -0,0 +1,317 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MongoDB.Bson; + +namespace Grand.Infrastructure.Tests.Audit; + +[TestClass] +public class AuditDiffTests +{ + private static readonly ISet NoIgnored = new HashSet(); + + private static IList Compare(BsonDocument before, BsonDocument after, string entityType = "Product", + ISet ignored = null) + { + var isSetting = entityType == "Setting"; + return AuditDiff.Compare(entityType, AuditSnapshot.Flatten(before, isSetting), + AuditSnapshot.Flatten(after, isSetting), ignored ?? NoIgnored); + } + + [TestMethod] + public void Compare_ScalarChanged_ReportsPathOldNew() + { + var changes = Compare(new BsonDocument("Price", 129), new BsonDocument("Price", 99)); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Price", changes[0].Path); + Assert.AreEqual("129", changes[0].OldValue); + Assert.AreEqual("99", changes[0].NewValue); + Assert.IsFalse(changes[0].Masked); + } + + [TestMethod] + public void Compare_Unchanged_ReturnsEmpty() + { + var doc = new BsonDocument { { "Name", "Shoes" }, { "Price", 10 } }; + + Assert.AreEqual(0, Compare(doc, doc.DeepClone().AsBsonDocument).Count); + } + + [TestMethod] + public void Compare_IgnoredPath_NotReported() + { + var before = new BsonDocument { { "Name", "a" }, { "UpdatedOnUtc", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) } }; + var after = new BsonDocument { { "Name", "a" }, { "UpdatedOnUtc", new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } }; + + Assert.AreEqual(0, Compare(before, after, ignored: new HashSet { "UpdatedOnUtc" }).Count); + } + + private static BsonDocument TierPrice(string id, int qty) + { + return new BsonDocument { { "_id", id }, { "Quantity", qty } }; + } + + [TestMethod] + public void Compare_ArrayOfDocsWithId_MatchesById() + { + var before = new BsonDocument("TierPrices", new BsonArray { TierPrice("a3", 5), TierPrice("b7", 1) }); + var after = new BsonDocument("TierPrices", new BsonArray { TierPrice("b7", 1), TierPrice("a3", 10) }); + + var changes = Compare(before, after); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("TierPrices[id=a3].Quantity", changes[0].Path); + Assert.AreEqual("5", changes[0].OldValue); + Assert.AreEqual("10", changes[0].NewValue); + } + + [TestMethod] + public void Compare_ArrayOfDocsWithId_AddedAndRemoved() + { + var before = new BsonDocument("TierPrices", new BsonArray { TierPrice("old", 1) }); + var after = new BsonDocument("TierPrices", new BsonArray { TierPrice("x", 2) }); + + var changes = Compare(before, after); + + Assert.AreEqual(2, changes.Count); + var added = changes.Single(c => c.Path == "TierPrices[id=x].Quantity"); + Assert.IsNull(added.OldValue); + Assert.AreEqual("2", added.NewValue); + var removed = changes.Single(c => c.Path == "TierPrices[id=old].Quantity"); + Assert.AreEqual("1", removed.OldValue); + Assert.IsNull(removed.NewValue); + } + + [TestMethod] + public void Compare_ScalarArray_OneValue() + { + var changes = Compare(new BsonDocument("Stores", new BsonArray { "a", "b" }), + new BsonDocument("Stores", new BsonArray { "b", "c" })); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Stores", changes[0].Path); + Assert.AreEqual("[\"a\", \"b\"]", changes[0].OldValue); + Assert.AreEqual("[\"b\", \"c\"]", changes[0].NewValue); + } + + [TestMethod] + public void Compare_EmptyStringAndEmptyArray_LikeMissing() + { + var before = new BsonDocument { { "Name", "" }, { "Stores", new BsonArray() } }; + + Assert.AreEqual(0, Compare(before, new BsonDocument()).Count); + } + + [TestMethod] + public void Compare_ArrayOfDocsWithoutId_ByIndex() + { + BsonDocument Locale(string value) => new() { { "LanguageId", "l" }, { "LocaleValue", value } }; + var before = new BsonDocument("Locales", new BsonArray { Locale("a"), Locale("b") }); + var after = new BsonDocument("Locales", new BsonArray { Locale("a"), Locale("c") }); + + var changes = Compare(before, after); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Locales[1].LocaleValue", changes[0].Path); + } + + private static BsonDocument UserField(string key, string value) + { + return new BsonDocument { { "Key", key }, { "Value", value }, { "StoreId", "" } }; + } + + [TestMethod] + public void Compare_KeyValueArray_MatchesByKey() + { + var before = new BsonDocument("UserFields", new BsonArray { UserField("A", "1"), UserField("B", "x") }); + var after = new BsonDocument("UserFields", new BsonArray { UserField("B", "x"), UserField("A", "2") }); + + var changes = Compare(before, after); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("UserFields[A].Value", changes[0].Path); + Assert.AreEqual("1", changes[0].OldValue); + Assert.AreEqual("2", changes[0].NewValue); + } + + [TestMethod] + public void Compare_KeyValueArray_AddedKey_ShowsValue() + { + var changes = Compare(new BsonDocument("UserFields", new BsonArray()), + new BsonDocument("UserFields", new BsonArray { UserField("ImpersonatedCustomerId", "c9") })); + + Assert.AreEqual("c9", changes.Single(c => c.Path == "UserFields[ImpersonatedCustomerId].Value").NewValue); + } + + [TestMethod] + public void Compare_KeyValueArray_SensitiveKey_Masked() + { + var before = new BsonDocument("UserFields", new BsonArray { UserField("PasswordRecoveryToken", "old-secret") }); + var after = new BsonDocument("UserFields", new BsonArray { + UserField("PasswordRecoveryToken", "new-secret"), UserField("TwoFactorSecretKey", "totp-secret") + }); + + var changes = Compare(before, after); + + //the changed token, and the Key and Value of the added secret + Assert.AreEqual(3, changes.Count); + foreach (var change in changes) + { + Assert.IsTrue(change.Masked, change.Path); + Assert.IsNull(change.OldValue); + Assert.IsNull(change.NewValue); + } + } + + [TestMethod] + public void Compare_MaskedLeaf_ValuesHidden() + { + var before = new BsonDocument { { "Password", "old" }, { "Config", new BsonDocument("ApiKey", "k1") } }; + var after = new BsonDocument { { "Password", "new" }, { "Config", new BsonDocument("ApiKey", "k2") } }; + + var changes = Compare(before, after); + + Assert.AreEqual(2, changes.Count); + foreach (var change in changes) + { + Assert.IsTrue(change.Masked, change.Path); + Assert.IsNull(change.OldValue); + Assert.IsNull(change.NewValue); + } + } + + [TestMethod] + public void Compare_LongValue_Truncated() + { + var changes = Compare(new BsonDocument("FullDescription", "short"), + new BsonDocument("FullDescription", new string('x', 5000))); + + Assert.AreEqual(1024, changes[0].NewValue.Length); + Assert.IsTrue(changes[0].Truncated); + } + + [TestMethod] + public void Compare_Binary_NotCompared_ListedAsChanged() + { + var changes = Compare(new BsonDocument("PictureBinary", new BsonBinaryData([1, 2, 3])), + new BsonDocument("PictureBinary", new BsonBinaryData([1, 2, 3]))); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("", changes[0].OldValue); + Assert.AreEqual("", changes[0].NewValue); + } + + [TestMethod] + public void Compare_SettingMetadata_DiffsInsideJson() + { + var changes = Compare(new BsonDocument("Metadata", "{\"RetentionDays\":365}"), + new BsonDocument("Metadata", "{\"RetentionDays\":180}"), "Setting"); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Metadata.RetentionDays", changes[0].Path); + Assert.AreEqual("365", changes[0].OldValue); + Assert.AreEqual("180", changes[0].NewValue); + } + + [TestMethod] + public void Compare_SettingMetadata_SecretMasked() + { + var changes = Compare(new BsonDocument("Metadata", "{\"ClientSecret\":\"x\"}"), + new BsonDocument("Metadata", "{\"ClientSecret\":\"y\"}"), "Setting"); + + Assert.AreEqual(1, changes.Count); + Assert.IsTrue(changes[0].Masked); + Assert.IsNull(changes[0].NewValue); + } + + [TestMethod] + public void Compare_SettingMetadata_InvalidJson_Masked() + { + var changes = Compare(new BsonDocument("Metadata", "{oops"), new BsonDocument("Metadata", "{}"), "Setting"); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Metadata", changes[0].Path); + Assert.IsTrue(changes[0].Masked); + } + + [TestMethod] + public void Compare_SettingMetadata_Null_DiffsInsideJson() + { + var changes = Compare(new BsonDocument("Metadata", BsonNull.Value), + new BsonDocument("Metadata", "{\"RetentionDays\":5,\"ClientSecret\":\"abc\"}"), "Setting"); + + Assert.AreEqual(2, changes.Count); + Assert.AreEqual("5", changes.Single(c => c.Path == "Metadata.RetentionDays").NewValue); + Assert.IsTrue(changes.Single(c => c.Path == "Metadata.ClientSecret").Masked); + } + + [TestMethod] + public void Compare_SettingMetadata_InvalidJson_ContentNeverStored() + { + var changes = Compare(new BsonDocument("Metadata", "{\"ClientSecret\":\"abc\""), + new BsonDocument("Metadata", "{\"ClientSecret\":\"xyz\""), "Setting"); + + Assert.AreEqual(1, changes.Count); + Assert.IsFalse((changes[0].OldValue ?? "").Contains("abc")); + Assert.IsFalse((changes[0].NewValue ?? "").Contains("xyz")); + } + + [TestMethod] + public void Compare_NullToDocument_NestedSecretMasked() + { + var changes = Compare(new BsonDocument("Config", BsonNull.Value), + new BsonDocument("Config", new BsonDocument { { "Url", "u" }, { "ApiKey", "k" } })); + + Assert.IsTrue(changes.Single(c => c.Path == "Config.ApiKey").Masked); + Assert.IsFalse(changes.Any(c => (c.NewValue ?? "").Contains("\"k\""))); + } + + [TestMethod] + public void Compare_ScalarToDocument_NestedSecretNotStored() + { + var changes = Compare(new BsonDocument("Config", "plain"), + new BsonDocument("Config", new BsonDocument("ApiKey", "k"))); + + Assert.IsTrue(changes.All(c => c.Masked || !(c.NewValue ?? "").Contains("ApiKey"))); + } + + [TestMethod] + public void Compare_KeyValueWithoutValue_NestedSecretNotStored() + { + var changes = Compare(new BsonDocument("Pairs", new BsonArray()), + new BsonDocument("Pairs", new BsonArray { + new BsonDocument { { "Key", "Gateway" }, { "Data", new BsonDocument("ApiKey", "k") } } + })); + + Assert.IsTrue(changes.Single(c => c.Path == "Pairs[Gateway].Data.ApiKey").Masked); + Assert.IsFalse(changes.Any(c => c.NewValue == "k")); + } + + [TestMethod] + public void Compare_ArrayWithDuplicateIds_DoesNotThrow() + { + var before = new BsonDocument("Items", new BsonArray { TierPrice("a", 1), TierPrice("a", 2) }); + var after = new BsonDocument("Items", new BsonArray { TierPrice("a", 3) }); + + Assert.IsNotNull(Compare(before, after)); + } + + [TestMethod] + public void Compare_BeforeNull_InsertListsSetFields() + { + var changes = Compare(null, new BsonDocument { { "_id", "1" }, { "Name", "Shoes" }, { "Empty", BsonNull.Value } }); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Name", changes[0].Path); + Assert.IsNull(changes[0].OldValue); + Assert.AreEqual("Shoes", changes[0].NewValue); + } + + [TestMethod] + public void Compare_AfterNull_DeleteReturnsEmptyChanges() + { + Assert.AreEqual(0, Compare(new BsonDocument("Name", "Shoes"), null).Count); + } +} diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditFilterIdTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditFilterIdTests.cs new file mode 100644 index 0000000000..2576cc8625 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditFilterIdTests.cs @@ -0,0 +1,95 @@ +using Grand.Data; +using Grand.Data.Audit; +using Grand.Domain; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Infrastructure.Tests.Audit; + +[TestClass] +public class AuditFilterIdTests +{ + [TestMethod] + public void TryGetId_ConstantEquality() + { + Assert.IsTrue(AuditFilterId.TryGetId(x => x.Id == "42", out var id)); + Assert.AreEqual("42", id); + } + + [TestMethod] + public void TryGetId_CapturedVariable() + { + var thing = new Thing { Id = "7" }; + + Assert.IsTrue(AuditFilterId.TryGetId(x => x.Id == thing.Id, out var id)); + Assert.AreEqual("7", id); + } + + [TestMethod] + public void TryGetId_ReversedOperands() + { + var expected = "9"; + + Assert.IsTrue(AuditFilterId.TryGetId(x => expected == x.Id, out var id)); + Assert.AreEqual("9", id); + } + + [TestMethod] + public void TryGetId_GetterThrows_FalseInsteadOfThrowing() + { + var source = new ThrowingSource(); + + Assert.IsFalse(AuditFilterId.TryGetId(x => x.Id == source.Id, out _)); + } + + private class ThrowingSource + { + public string Id => throw new InvalidOperationException("broken getter"); + } + + [TestMethod] + public void TryGetId_OtherFilter_False() + { + Assert.IsFalse(AuditFilterId.TryGetId(x => x.Name == "a", out _)); + } + + [TestMethod] + public void TryGetId_CompoundFilter_False() + { + var id = "1"; + + Assert.IsFalse(AuditFilterId.TryGetId(x => x.Id == id && x.Published, out _)); + } + + [TestMethod] + public void MemberPath_Simple() + { + Assert.AreEqual("Name", AuditFilterId.MemberPath(x => x.Name)); + } + + [TestMethod] + public void MemberPath_Nested() + { + Assert.AreEqual("Inner.Value", AuditFilterId.MemberPath(x => x.Inner.Value)); + } + + [TestMethod] + public void MemberPath_FromUpdateBuilder() + { + var builder = UpdateBuilder.Create().Set(x => x.Published, true).Set(x => x.Name, "a"); + + CollectionAssert.AreEquivalent(new[] { "Published", "Name" }, + AuditFilterId.MemberPaths(builder).ToArray()); + } + + public class Thing : BaseEntity + { + public string Name { get; set; } + public bool Published { get; set; } + public InnerThing Inner { get; set; } + } + + public class InnerThing + { + public int Value { get; set; } + } +} diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs new file mode 100644 index 0000000000..809ab97836 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs @@ -0,0 +1,542 @@ +using Grand.Data.Audit; +using Grand.Domain; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; +using Grand.Mediator; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Infrastructure.Tests.Audit; + +[TestClass] +public class AuditTrackerTests +{ + private readonly Dictionary _store = new(); + private int _loadCalls; + private Func, Task>> _access; + private Mock _mediator; + private CapturingWriter _writer; + private AuditTracker _tracker; + + private static readonly AuditActor Actor = new(AuditSource.Admin, "c1", "anna@shop.com", null, "s1", null, + "10.0.0.1", "UA", "Admin / Product / Edit", "op-1"); + + [TestInitialize] + public void Init() + { + _writer = new CapturingWriter(); + _mediator = new Mock(); + _tracker = new AuditTracker(_writer, _mediator.Object, NullLogger.Instance); + _access = ids => + { + _loadCalls++; + IList found = ids.Where(_store.ContainsKey).Select(id => Clone(_store[id])).ToList(); + return Task.FromResult(found); + }; + } + + private static TrackedThing Clone(TrackedThing t) + { + return new TrackedThing { Id = t.Id, Name = t.Name, Price = t.Price, StockQuantity = t.StockQuantity, Ticks = t.Ticks, StoreId = t.StoreId }; + } + + private TrackedThing Put(string id, string name = "Shoes", int price = 10, int stock = 5) + { + var thing = new TrackedThing { Id = id, Name = name, Price = price, StockQuantity = stock }; + _store[id] = thing; + return thing; + } + + private List Written => _writer.Batches.SelectMany(b => b).ToList(); + + [TestMethod] + public async Task Inactive_BeforeWrite_DoesNotRead() + { + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access); + _store["1"].Price = 20; + await _tracker.FlushAsync(); + + Assert.AreEqual(0, _loadCalls); + Assert.AreEqual(0, Written.Count); + } + + [TestMethod] + public async Task Update_ReadOnceAcrossWrites() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Name"]); + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + + Assert.AreEqual(1, _loadCalls); + } + + [TestMethod] + public async Task Update_Flush_WritesOneEntryWithDiffAndActor() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Price = 20; + await _tracker.FlushAsync(); + + var entry = Written.Single(); + Assert.AreEqual(AuditKind.Update, entry.Kind); + Assert.AreEqual(AuditSource.Admin, entry.Source); + Assert.AreEqual("c1", entry.ActorCustomerId); + Assert.AreEqual("anna@shop.com", entry.ActorEmail); + Assert.AreEqual("s1", entry.StoreId); + Assert.AreEqual("10.0.0.1", entry.IpAddress); + Assert.AreEqual("Admin / Product / Edit", entry.Action); + Assert.AreEqual("op-1", entry.OperationId); + Assert.AreEqual(nameof(TrackedThing), entry.EntityType); + Assert.AreEqual("1", entry.EntityId); + Assert.AreEqual("Shoes", entry.EntityName); + Assert.AreEqual("Price", entry.Changes.Single().Path); + Assert.AreEqual("10", entry.Changes.Single().OldValue); + Assert.AreEqual("20", entry.Changes.Single().NewValue); + } + + [TestMethod] + public async Task Update_NoRealChange_NoEntry() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + await _tracker.FlushAsync(); + + Assert.AreEqual(0, Written.Count); + } + + [TestMethod] + public async Task Update_OnlyConcurrencyTicksChanged_NoEntry() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access); + _store["1"].Ticks = 638_000_000_000_000_000; + await _tracker.FlushAsync(); + + Assert.AreEqual(0, Written.Count); + } + + [TestMethod] + public async Task Update_RecordsTheStoreTheRecordBelongsTo() + { + _tracker.Activate(Actor); + Put("1").StoreId = ""; + Put("2").StoreId = "s2"; + + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + await _tracker.BeforeWriteAsync("2", false, _access, ["Price"]); + _store["1"].Price = 1; + _store["2"].Price = 2; + await _tracker.FlushAsync(); + + Assert.AreEqual("", Written.Single(e => e.EntityId == "1").EntityStoreId); + Assert.AreEqual("s2", Written.Single(e => e.EntityId == "2").EntityStoreId); + } + + [TestMethod] + public async Task FullUpdate_ThenDelete_KindDelete() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access); + var updated = Clone(_store["1"]); + updated.Price = 20; + _store["1"] = updated; + _tracker.AfterFullUpdate(updated); + await _tracker.BeforeWriteAsync("1", false, _access); + _store.Remove("1"); + await _tracker.FlushAsync(); + + Assert.AreEqual(AuditKind.Delete, Written.Single().Kind); + } + + [TestMethod] + public async Task Insert_FullUpdate_Delete_InsertedAndDeletedDetails() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", true, _access); + await _tracker.BeforeWriteAsync("1", false, _access); + _tracker.AfterFullUpdate(_store["1"]); + await _tracker.BeforeWriteAsync("1", false, _access); + _store.Remove("1"); + await _tracker.FlushAsync(); + + var entry = Written.Single(); + Assert.AreEqual(AuditKind.Delete, entry.Kind); + Assert.AreEqual("inserted and deleted in the same operation", entry.Details); + } + + [TestMethod] + public async Task BeforeWrite_OnlyActivityFields_NoReadNoEntry() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["LastActivityDateUtc", "LastIpAddress", "LastVisitedPage"]); + await _tracker.FlushAsync(); + + Assert.AreEqual(0, _loadCalls); + Assert.AreEqual(0, Written.Count); + } + + [TestMethod] + public async Task BeforeRead_Throws_DoesNotFailTheWrite() + { + _tracker.Activate(Actor); + Func, Task>> failing = + _ => throw new InvalidOperationException("db down"); + + await _tracker.BeforeWriteAsync("1", false, failing, ["Price"]); + await _tracker.FlushAsync(); + } + + [TestMethod] + public async Task Insert_NoBeforeRead_KindInsert() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", true, _access); + Assert.AreEqual(0, _loadCalls); + await _tracker.FlushAsync(); + + var entry = Written.Single(); + Assert.AreEqual(AuditKind.Insert, entry.Kind); + Assert.IsTrue(entry.Changes.Any(c => c.Path == "Name" && c.NewValue == "Shoes")); + } + + [TestMethod] + public async Task Delete_KindDelete() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access); + _store.Remove("1"); + await _tracker.FlushAsync(); + + var entry = Written.Single(); + Assert.AreEqual(AuditKind.Delete, entry.Kind); + Assert.AreEqual("Shoes", entry.EntityName); + } + + [TestMethod] + public async Task InsertThenDelete_SingleDeleteEntryWithDetails() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", true, _access); + await _tracker.BeforeWriteAsync("1", false, _access); + _store.Remove("1"); + await _tracker.FlushAsync(); + + var entry = Written.Single(); + Assert.AreEqual(AuditKind.Delete, entry.Kind); + Assert.AreEqual("inserted and deleted in the same operation", entry.Details); + } + + [TestMethod] + public async Task Batch_At200_FlushesBeforeTouching201st() + { + _tracker.Activate(Actor); + for (var i = 0; i < 201; i++) + Put(i.ToString()); + + for (var i = 0; i < 200; i++) + { + await _tracker.BeforeWriteAsync(i.ToString(), false, _access, ["Price"]); + _store[i.ToString()].Price = 99; + } + + Assert.AreEqual(0, _writer.Batches.Count); + await _tracker.BeforeWriteAsync("200", false, _access, ["Price"]); + + Assert.AreEqual(1, _writer.Batches.Count); + Assert.AreEqual(200, _writer.Batches[0].Count); + } + + [TestMethod] + public async Task Retouch_AfterFlush_SecondEntryUsesFreshBefore() + { + _tracker.Activate(Actor); + Put("1"); + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Price = 20; + await _tracker.FlushAsync(); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Price = 30; + await _tracker.FlushAsync(); + + Assert.AreEqual(2, Written.Count); + Assert.AreEqual("20", Written[1].Changes.Single().OldValue); + Assert.AreEqual("30", Written[1].Changes.Single().NewValue); + } + + [TestMethod] + public async Task Flush_LoadsAfterOncePerType() + { + _tracker.Activate(Actor); + Put("1"); + Put("2"); + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + await _tracker.BeforeWriteAsync("2", false, _access, ["Price"]); + _loadCalls = 0; + + await _tracker.FlushAsync(); + + Assert.AreEqual(1, _loadCalls); + } + + [TestMethod] + public async Task Bulk_RecordedAsIs() + { + _tracker.Activate(Actor); + + _tracker.RecordBulk("UpdateManyAsync", "x => x.Price > 10"); + await _tracker.FlushAsync(); + + var entry = Written.Single(); + Assert.AreEqual(AuditKind.Bulk, entry.Kind); + Assert.AreEqual(nameof(TrackedThing), entry.EntityType); + StringAssert.Contains(entry.Details, "UpdateManyAsync"); + StringAssert.Contains(entry.Details, "x.Price > 10"); + Assert.AreEqual("op-1", entry.OperationId); + } + + [TestMethod] + public void ExcludedType_NotAudited() + { + Assert.IsFalse(_tracker.IsAudited(typeof(AuditEntry))); + Assert.IsTrue(_tracker.IsAudited(typeof(TrackedThing))); + } + + [TestMethod] + public async Task Flush_WriterThrows_Logged_NotRethrown() + { + _tracker.Activate(Actor); + _writer.Throw = true; + Put("1"); + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Price = 20; + + await _tracker.FlushAsync(); + } + + [TestMethod] + public async Task Flush_PublishesNotificationPerEntry() + { + _tracker.Activate(Actor); + Put("1"); + Put("2"); + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + await _tracker.BeforeWriteAsync("2", false, _access, ["Price"]); + _store["1"].Price = 20; + _store["2"].Price = 30; + + await _tracker.FlushAsync(); + + _mediator.Verify(m => m.Publish(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [TestMethod] + public async Task PartialWrite_ForeignChangeOnOtherPath_Ignored() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Name"]); + _store["1"].Name = "Boots"; + _store["1"].StockQuantity = 4; //a customer bought one meanwhile + await _tracker.FlushAsync(); + + Assert.AreEqual("Name", Written.Single().Changes.Single().Path); + } + + [TestMethod] + public async Task PartialWrites_UnionOfPaths() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Name"]); + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Name = "Boots"; + _store["1"].Price = 20; + await _tracker.FlushAsync(); + + CollectionAssert.AreEquivalent(new[] { "Name", "Price" }, + Written.Single().Changes.Select(c => c.Path).ToArray()); + } + + [TestMethod] + public async Task FullUpdate_UsesInMemoryAfter_NoReadAtFlush() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access); + var updated = Clone(_store["1"]); + updated.Price = 20; + _store["1"] = updated; + _tracker.AfterFullUpdate(updated); + _store["1"].StockQuantity = 4; //a customer bought one after the admin's save + await _tracker.FlushAsync(); + + Assert.AreEqual(1, _loadCalls); + Assert.AreEqual("Price", Written.Single().Changes.Single().Path); + } + + [TestMethod] + public async Task FullUpdate_ThenPartial_ReReadsAtFlush() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access); + var updated = Clone(_store["1"]); + updated.Price = 20; + _store["1"] = updated; + _tracker.AfterFullUpdate(updated); + await _tracker.BeforeWriteAsync("1", false, _access, ["Name"]); + _store["1"].Name = "Boots"; + await _tracker.FlushAsync(); + + Assert.AreEqual(2, _loadCalls); + CollectionAssert.AreEquivalent(new[] { "Name", "Price" }, + Written.Single().Changes.Select(c => c.Path).ToArray()); + } + + [TestMethod] + public async Task WriteByAuditEntryCreatedHandler_NotTracked() + { + _tracker.Activate(Actor); + Put("1"); + Put("outbox"); + var handlerSawActive = true; + _mediator.Setup(m => m.Publish(It.IsAny(), It.IsAny())) + .Returns(async () => + { + handlerSawActive = _tracker.IsActive; + await _tracker.BeforeWriteAsync("outbox", false, _access); + }); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Price = 20; + await _tracker.CompleteAsync(); + + Assert.IsFalse(handlerSawActive); + Assert.AreEqual("1", Written.Single().EntityId); + } + + [TestMethod] + public async Task Complete_FlushesAndStopsTracking() + { + _tracker.Activate(Actor); + Put("1"); + + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + _store["1"].Price = 20; + await _tracker.CompleteAsync(); + var loadsAfterComplete = _loadCalls; + await _tracker.BeforeWriteAsync("1", false, _access, ["Price"]); + + Assert.AreEqual(1, Written.Count); + Assert.IsFalse(_tracker.IsActive); + Assert.AreEqual(loadsAfterComplete, _loadCalls); + } + + [TestMethod] + public async Task AfterBatchFlushPublish_CallerStillTracked() + { + _tracker.Activate(Actor); + for (var i = 0; i <= AuditTracker.BatchSize; i++) + { + Put(i.ToString()); + //the 201st write flushes the first 200 and publishes them before it is queued itself + await _tracker.BeforeWriteAsync(i.ToString(), false, _access, ["Price"]); + _store[i.ToString()].Price = 20; + } + + Assert.IsTrue(_tracker.IsActive); + await _tracker.FlushAsync(); + Assert.AreEqual(AuditTracker.BatchSize + 1, Written.Count); + } + + [TestMethod] + public async Task AfterFullUpdate_SnapshotFails_DoesNotThrow() + { + _tracker.Activate(Actor); + var thing = new UnserializableThing { Id = "1" }; + //an insert queues the entity without reading it, so the snapshot after the update is the first one + await _tracker.BeforeWriteAsync("1", true, _ => Task.FromResult>([])); + + _tracker.AfterFullUpdate(thing); + } + + public class UnserializableThing : BaseEntity + { + public string Broken { get => throw new InvalidOperationException("broken getter"); set { } } + } + + [TestMethod] + public async Task EntityName_FromAuditNameProperty() + { + _tracker.Activate(Actor); + var account = new LabelledThing { Id = "a1", Email = "anna@shop.com", Note = "x" }; + Func, Task>> load = + _ => Task.FromResult>([account]); + + await _tracker.BeforeWriteAsync("a1", true, load); + await _tracker.FlushAsync(); + + Assert.AreEqual("anna@shop.com", Written.Single().EntityName); + } + + public class LabelledThing : BaseEntity + { + [AuditName] + public string Email { get; set; } + + public string Note { get; set; } + } + + public class TrackedThing : BaseEntity + { + public string Name { get; set; } + public int Price { get; set; } + public int StockQuantity { get; set; } + public long Ticks { get; set; } + public string StoreId { get; set; } + } + + private class CapturingWriter : IAuditEntryWriter + { + public List> Batches { get; } = []; + public bool Throw { get; set; } + + public Task InsertManyAsync(IReadOnlyCollection entries) + { + if (Throw) + throw new InvalidOperationException("db down"); + Batches.Add(entries.ToList()); + return Task.CompletedTask; + } + } +} diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs new file mode 100644 index 0000000000..779ee96548 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs @@ -0,0 +1,202 @@ +using Grand.Data; +using Grand.Data.Audit; +using Grand.Data.LiteDb; +using Grand.Domain; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; +using Grand.Mediator; +using LiteDB; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Infrastructure.Tests.Audit; + +[TestClass] +public class AuditingLiteDBRepositoryTests +{ + private static readonly AuditActor Actor = new(AuditSource.Admin, "c1", "anna@shop.com", null, null, null, + "10.0.0.1", "UA", "Admin / Product / Edit", "op-1"); + + private LiteDatabase _database; + private AuditingLiteDBRepository _repository; + private AuditingLiteDBRepository _auditRepository; + private AuditTracker _tracker; + private List _written; + + [TestInitialize] + public void Init() + { + _database = new LiteDatabase(new MemoryStream()); + _written = []; + var writer = new Mock(); + writer.Setup(w => w.InsertManyAsync(It.IsAny>())) + .Callback>(e => _written.AddRange(e)) + .Returns(Task.CompletedTask); + _tracker = new AuditTracker(writer.Object, new Mock().Object, NullLogger.Instance); + var auditInfo = new Mock(); + auditInfo.Setup(a => a.GetCurrentDateTime()).Returns(DateTime.UtcNow); + _repository = new AuditingLiteDBRepository(_database, auditInfo.Object, _tracker); + _auditRepository = new AuditingLiteDBRepository(_database, auditInfo.Object, _tracker); + } + + [TestCleanup] + public void Cleanup() + { + _database.Dispose(); + } + + private async Task Seed() + { + var gadget = new Gadget { Name = "Lamp", Price = 10, Items = [new GadgetItem { Id = "i1", Qty = 1 }] }; + await _repository.InsertAsync(gadget); + return gadget; + } + + [TestMethod] + public async Task Inactive_WritesPassThrough_NoEntries() + { + var gadget = await Seed(); + await _repository.UpdateField(gadget.Id, x => x.Price, 20); + await _tracker.FlushAsync(); + + Assert.AreEqual(0, _written.Count); + Assert.AreEqual(20, (await _repository.GetByIdAsync(gadget.Id)).Price); + } + + [TestMethod] + public async Task UpdateField_Then_Flush_UpdateEntry() + { + var gadget = await Seed(); + _tracker.Activate(Actor); + + await _repository.UpdateField(gadget.Id, x => x.Price, 20); + await _tracker.FlushAsync(); + + var entry = _written.Single(); + Assert.AreEqual(AuditKind.Update, entry.Kind); + Assert.AreEqual("Price", entry.Changes.Single().Path); + Assert.AreEqual("20", entry.Changes.Single().NewValue); + } + + [TestMethod] + public async Task UpdateCollectionFieldItem_DiffPathById() + { + var gadget = await Seed(); + _tracker.Activate(Actor); + + await _repository.UpdateCollectionFieldItem(gadget.Id, x => x.Items, i => i.Id == "i1", + new GadgetItem { Id = "i1", Qty = 5 }); + await _tracker.FlushAsync(); + + var change = _written.Single().Changes.Single(); + Assert.AreEqual("Items[id=i1].Qty", change.Path); + Assert.AreEqual("1", change.OldValue); + Assert.AreEqual("5", change.NewValue); + } + + [TestMethod] + public async Task InsertAsync_InsertEntryWithId() + { + _tracker.Activate(Actor); + + var gadget = await Seed(); + await _tracker.FlushAsync(); + + var entry = _written.Single(); + Assert.AreEqual(AuditKind.Insert, entry.Kind); + Assert.AreEqual(gadget.Id, entry.EntityId); + Assert.AreEqual("Lamp", entry.EntityName); + } + + [TestMethod] + public async Task DeleteAsync_DeleteEntry() + { + var gadget = await Seed(); + _tracker.Activate(Actor); + + await _repository.DeleteAsync(gadget); + await _tracker.FlushAsync(); + + var entry = _written.Single(); + Assert.AreEqual(AuditKind.Delete, entry.Kind); + Assert.AreEqual(gadget.Id, entry.EntityId); + } + + [TestMethod] + public async Task UpdateAsync_FullUpdate_UpdateEntry() + { + var gadget = await Seed(); + _tracker.Activate(Actor); + + gadget.Name = "Desk lamp"; + await _repository.UpdateAsync(gadget); + await _tracker.FlushAsync(); + + Assert.AreEqual("Name", _written.Single().Changes.Single().Path); + } + + [TestMethod] + public async Task UpdateOneAsync_ByIdFilter_EntityEntry() + { + var gadget = await Seed(); + _tracker.Activate(Actor); + + await _repository.UpdateOneAsync(x => x.Id == gadget.Id, UpdateBuilder.Create().Set(x => x.Price, 30)); + await _tracker.FlushAsync(); + + var entry = _written.Single(); + Assert.AreEqual(AuditKind.Update, entry.Kind); + Assert.AreEqual("Price", entry.Changes.Single().Path); + } + + [TestMethod] + public async Task UpdateManyAsync_BulkEntry() + { + await Seed(); + _tracker.Activate(Actor); + + await _repository.UpdateManyAsync(x => x.Price > 5, UpdateBuilder.Create().Set(x => x.Price, 1)); + await _tracker.FlushAsync(); + + var entry = _written.Single(); + Assert.AreEqual(AuditKind.Bulk, entry.Kind); + StringAssert.Contains(entry.Details, "UpdateManyAsync"); + } + + [TestMethod] + public async Task AuditIndexes_CreateOnLiteDb_Twice() + { + var context = new LiteDBContext(_database); + + await AuditIndexes.Create(context, _auditRepository); + await AuditIndexes.Create(context, _auditRepository); + + var indexes = _database.GetCollection("$indexes").Query().Where("collection = @0", nameof(AuditEntry)).ToList(); + Assert.IsTrue(indexes.Count >= 5, string.Join(", ", indexes.Select(i => i["name"].AsString))); + } + + [TestMethod] + public async Task ExcludedType_NoEntry() + { + _tracker.Activate(Actor); + + await _auditRepository.InsertAsync(new AuditEntry { Details = "x" }); + await _tracker.FlushAsync(); + + Assert.AreEqual(0, _written.Count); + } + + public class Gadget : BaseEntity + { + public string Name { get; set; } + public int Price { get; set; } + public IList Items { get; set; } = []; + } + + public class GadgetItem + { + public string Id { get; set; } + public int Qty { get; set; } + } +} diff --git a/src/Tests/Grand.Modules.Tests/Installer/PermissionSeedTests.cs b/src/Tests/Grand.Modules.Tests/Installer/PermissionSeedTests.cs new file mode 100644 index 0000000000..d4729746e8 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Installer/PermissionSeedTests.cs @@ -0,0 +1,30 @@ +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Module.Installer.Extensions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Installer; + +/// +/// The installer seeds permissions from its own list, not from PermissionProvider, so a new permission +/// has to be added there too or fresh installations never get it. +/// +[TestClass] +public class PermissionSeedTests +{ + [TestMethod] + public void InstallPermissions_IncludeManageAuditLog() + { + Assert.IsTrue(PermissionExtensions.Permissions().Any(p => p.SystemName == PermissionSystemName.AuditLog)); + } + + [TestMethod] + public void InstallDefaultPermissions_OnlyAdministratorsGetManageAuditLog() + { + var holders = PermissionExtensions.DefaultPermissions() + .Where(d => d.Permissions.Any(p => p.SystemName == PermissionSystemName.AuditLog)) + .Select(d => d.CustomerGroupSystemName).ToList(); + + CollectionAssert.AreEqual(new[] { SystemCustomerGroupNames.Administrators }, holders); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs new file mode 100644 index 0000000000..8b4f566224 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs @@ -0,0 +1,54 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Infrastructure.Configuration; +using Grand.Module.ScheduledTasks.BackgroundServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Modules.Tests.Services.BackgroundService; + +[TestClass] +public class AuditLogRetentionScheduleTaskTests +{ + private Mock _auditService; + private DateTime _cutoff; + + [TestInitialize] + public void Init() + { + _auditService = new Mock(); + _auditService.Setup(s => s.DeleteOlderThan(It.IsAny())) + .Callback(d => _cutoff = d) + .ReturnsAsync(0); + } + + [TestMethod] + public async Task Execute_DeletesOlderThanRetention() + { + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditConfig { RetentionDays = 30 }); + + await task.Execute(CancellationToken.None); + + var expected = DateTime.UtcNow.AddDays(-30); + Assert.IsTrue(Math.Abs((_cutoff - expected).TotalSeconds) < 5, _cutoff.ToString("o")); + } + + [TestMethod] + public async Task Execute_Cancelled_DeletesNothing() + { + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditConfig()); + + await Assert.ThrowsExactlyAsync(() => task.Execute(new CancellationToken(true))); + + _auditService.Verify(s => s.DeleteOlderThan(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Execute_RetentionZero_KeepsEverything() + { + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditConfig { RetentionDays = 0 }); + + await task.Execute(CancellationToken.None); + + _auditService.Verify(s => s.DeleteOlderThan(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs new file mode 100644 index 0000000000..e09c063490 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs @@ -0,0 +1,308 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain; +using Grand.Domain.Audit; +using Grand.Domain.Permissions; +using Grand.Domain.Stores; +using Grand.Infrastructure.Validators; +using Grand.Web.Admin.Controllers; +using Grand.Web.Admin.Models.AuditLog; +using Grand.Web.Common.Audit; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Text; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class AuditLogControllerTests +{ + private Mock _auditService; + private Mock _recorder; + private AuditLogController _controller; + private DefaultHttpContext _httpContext; + + [TestInitialize] + public void Setup() + { + _auditService = new Mock(); + _auditService.Setup(s => s.GetEntityTypes()).ReturnsAsync(new List { "Product" }); + _recorder = new Mock(); + var storeService = new Mock(); + storeService.Setup(s => s.GetAllStores()).ReturnsAsync(new List { new() { Id = "s2", Shortcut = "Store B" } }); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(key => key); + _controller = new AuditLogController(_auditService.Object, _recorder.Object, + storeService.Object, translation.Object); + _httpContext = new DefaultHttpContext(); + _httpContext.Response.Body = new MemoryStream(); + _controller.ControllerContext = new ControllerContext { HttpContext = _httpContext }; + } + + private static async IAsyncEnumerable Stream(params AuditEntry[] entries) + { + foreach (var entry in entries) + yield return entry; + await Task.CompletedTask; + } + + private string ResponseText() + { + return Encoding.UTF8.GetString(((MemoryStream)_httpContext.Response.Body).ToArray()).TrimStart(''); + } + + [TestMethod] + public async Task List_Get_RecordsAuditLogViewed_DefaultsToLastSevenDays() + { + var result = (ViewResult)await _controller.List(null, null, null); + + var model = (AuditLogListModel)result.Model!; + Assert.IsTrue((DateTime.UtcNow.AddDays(-7) - model.FromUtc!.Value).Duration() < TimeSpan.FromMinutes(1)); + _recorder.Verify(r => r.AuditLogViewed(), Times.Once); + } + + [TestMethod] + public async Task List_Get_ForOneRecord_ShowsWholeHistory() + { + var result = (ViewResult)await _controller.List("Product", "p1", null); + + var model = (AuditLogListModel)result.Model!; + Assert.AreEqual("Product", model.EntityType); + Assert.AreEqual("p1", model.EntityId); + Assert.IsNull(model.FromUtc); + } + + [TestMethod] + public async Task List_Post_MapsFiltersToSearch() + { + AuditEntrySearch search = null; + _auditService.Setup(s => s.GetEntries(It.IsAny(), 1, 20)) + .Callback((s, _, _) => search = s) + .ReturnsAsync(new PagedList(new List { + new() { EntityType = "Product", EntityName = "Lamp", Changes = [new AuditChange { Path = "Price" }] } + }, 0, 20)); + + var result = (JsonResult)await _controller.List(new DataSourceRequest { Page = 2, PageSize = 20 }, + new AuditLogListModel { EntityType = "Product", EntityId = "p1", ActorEmail = "anna", Kind = AuditKind.Update }); + + Assert.AreEqual("Product", search.EntityType); + Assert.AreEqual("p1", search.EntityId); + Assert.AreEqual("anna", search.ActorEmail); + Assert.AreEqual(AuditKind.Update, search.Kind); + var data = ((DataSourceResult)result.Value!).Data.Cast().Single(); + Assert.AreEqual("Price", data.ChangedFields); + Assert.IsNull(data.ChangesText); + } + + [TestMethod] + public async Task List_Post_DatesAreUtc() + { + AuditEntrySearch search = null; + _auditService.Setup(s => s.GetEntries(It.IsAny(), 0, 20)) + .Callback((s, _, _) => search = s) + .ReturnsAsync(new PagedList(new List(), 0, 20)); + + //as model binding produces them from the date inputs + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 20 }, new AuditLogListModel { + FromUtc = new DateTime(2026, 10, 21), ToUtc = new DateTime(2026, 10, 22) + }); + + Assert.AreEqual(DateTimeKind.Utc, search.FromUtc!.Value.Kind); + Assert.AreEqual(new DateTime(2026, 10, 21, 0, 0, 0, DateTimeKind.Utc), search.FromUtc); + Assert.AreEqual(DateTimeKind.Utc, search.ToUtc!.Value.Kind); + Assert.AreEqual(new DateTime(2026, 10, 23, 0, 0, 0, DateTimeKind.Utc).AddTicks(-1), search.ToUtc); + } + + [TestMethod] + public async Task Detail_NotFound_Returns404() + { + Assert.IsInstanceOfType(await _controller.Detail("missing")); + } + + [TestMethod] + public async Task ExportCsv_WritesHeaderAndOneRowPerChange() + { + _auditService.Setup(s => s.StreamEntries(It.IsAny(), It.IsAny())) + .Returns(Stream(new AuditEntry { + Kind = AuditKind.Update, EntityType = "Product", + Changes = [new AuditChange { Path = "Price", OldValue = "1", NewValue = "2" }, new AuditChange { Path = "Name" }] + }, new AuditEntry { Kind = AuditKind.SignIn })); + + await _controller.ExportCsv(new AuditLogListModel()); + + Assert.AreEqual("text/csv; charset=utf-8", _httpContext.Response.ContentType); + var lines = ResponseText().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(4, lines.Length); + StringAssert.StartsWith(lines[0], "CreatedOnUtc,Kind,Source,ActorEmail"); + StringAssert.Contains(lines[1], ",Price,1,2,"); + } + + [TestMethod] + public async Task ExportCsv_EscapesQuotesAndFormulaPrefix() + { + _auditService.Setup(s => s.StreamEntries(It.IsAny(), It.IsAny())) + .Returns(Stream(new AuditEntry { + Kind = AuditKind.Update, + Changes = [new AuditChange { Path = "Name", OldValue = "say \"hi\", ok", NewValue = "=HYPERLINK(1)" }] + })); + + await _controller.ExportCsv(new AuditLogListModel()); + + var text = ResponseText(); + StringAssert.Contains(text, "\"say \"\"hi\"\", ok\""); + StringAssert.Contains(text, "'=HYPERLINK(1)"); + } + + private static async IAsyncEnumerable FailingStream() + { + yield return new AuditEntry(); + await Task.CompletedTask; + throw new OperationCanceledException("client went away"); + } + + [TestMethod] + public async Task ExportCsv_StreamFailsMidway_StillRecordsAuditLogExported() + { + _auditService.Setup(s => s.StreamEntries(It.IsAny(), It.IsAny())) + .Returns(FailingStream()); + + await Assert.ThrowsExactlyAsync(() => _controller.ExportCsv(new AuditLogListModel())); + + _recorder.Verify(r => r.AuditLogExported(It.Is(s => s.Contains("incomplete")), 1), Times.Once); + } + + [TestMethod] + public async Task ExportCsv_HasEntityStoreIdColumn() + { + _auditService.Setup(s => s.StreamEntries(It.IsAny(), It.IsAny())) + .Returns(Stream(new AuditEntry { EntityType = "Setting", EntityStoreId = "s2" })); + + await _controller.ExportCsv(new AuditLogListModel()); + + var lines = ResponseText().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + var columns = lines[0].Split(','); + var cells = lines[1].Split(','); + Assert.AreEqual("s2", cells[Array.IndexOf(columns, "EntityStoreId")]); + } + + [TestMethod] + public async Task ExportCsv_RecordsAuditLogExported() + { + _auditService.Setup(s => s.StreamEntries(It.IsAny(), It.IsAny())) + .Returns(Stream(new AuditEntry(), new AuditEntry())); + + await _controller.ExportCsv(new AuditLogListModel { EntityType = "Product" }); + + _recorder.Verify(r => r.AuditLogExported(It.Is(s => s.Contains("Product")), 2), Times.Once); + } + + [TestMethod] + public void Controller_HasPermissionAuthorizeAuditLog() + { + var attribute = typeof(AuditLogController).GetCustomAttributes(typeof(PermissionAuthorizeAttribute), true) + .Cast().Single(); + + Assert.AreEqual(PermissionSystemName.AuditLog, attribute.Permission); + } + + [TestMethod] + public void HistoryPopup_PassesTypeAndId() + { + var result = (ViewResult)_controller.HistoryPopup("Product", "p1", null); + + var model = (AuditHistoryPopupModel)result.Model!; + Assert.AreEqual("Product", model.EntityType); + Assert.AreEqual("p1", model.EntityId); + } + + [TestMethod] + public async Task HistoryList_WithId_PageSize20() + { + AuditEntrySearch search = null; + _auditService.Setup(s => s.GetEntries(It.IsAny(), 2, 20)) + .Callback((s, _, _) => search = s) + .ReturnsAsync(new PagedList(new List { + new() { Changes = [new AuditChange { Path = "Price", OldValue = "129", NewValue = "99" }] } + }, 0, 20)); + + var result = (JsonResult)await _controller.HistoryList(new DataSourceRequest { Page = 3, PageSize = 50 }, + "Product", "p1", null); + + Assert.AreEqual("Product", search.EntityType); + Assert.AreEqual("p1", search.EntityId); + var row = ((DataSourceResult)result.Value!).Data.Cast().Single(); + Assert.AreEqual("Price: 129 → 99", row.ChangesText); + } + + [TestMethod] + public async Task HistoryList_EntityNames_FilterTheSettingClasses() + { + AuditEntrySearch search = null; + _auditService.Setup(s => s.GetEntries(It.IsAny(), 0, 100)) + .Callback((s, _, _) => search = s) + .ReturnsAsync(new PagedList(new List(), 0, 100)); + + await _controller.HistoryList(new DataSourceRequest(), "Setting", null, "catalogsettings, ordersettings"); + + CollectionAssert.AreEqual(new[] { "catalogsettings", "ordersettings" }, search.EntityNames.ToArray()); + } + + [TestMethod] + public async Task Rows_ShowWhichStoreTheRecordAppliesTo() + { + _auditService.Setup(s => s.GetEntries(It.IsAny(), 0, 100)) + .ReturnsAsync(new PagedList(new List { + new() { EntityType = "Setting", EntityName = "catalogsettings", EntityStoreId = "" }, + new() { EntityType = "Setting", EntityName = "catalogsettings", EntityStoreId = "s2" }, + new() { EntityType = "Product", EntityName = "Lamp" } + }, 0, 100)); + + var result = (JsonResult)await _controller.HistoryList(new DataSourceRequest(), "Setting", null, null); + + var what = ((DataSourceResult)result.Value!).Data.Cast().Select(r => r.What).ToArray(); + CollectionAssert.AreEqual(new[] { + "Setting catalogsettings (Admin.System.AuditLog.AllStores)", + "Setting catalogsettings (Store B)", + "Product Lamp" + }, what); + } + + [TestMethod] + public async Task HistoryList_TypeOnly_Newest100() + { + AuditEntrySearch search = null; + _auditService.Setup(s => s.GetEntries(It.IsAny(), 0, 100)) + .Callback((s, _, _) => search = s) + .ReturnsAsync(new PagedList(new List(), 0, 100)); + + await _controller.HistoryList(new DataSourceRequest { Page = 4, PageSize = 20 }, "SalesEmployee", null, null); + + Assert.AreEqual("SalesEmployee", search.EntityType); + Assert.IsNull(search.EntityId); + } + + [TestMethod] + public void AuditLogViews_NeverRenderStoredValuesRaw() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "src", "Web"))) + dir = dir.Parent; + Assert.IsNotNull(dir, "repository root not found"); + var views = Directory.GetFiles( + Path.Combine(dir.FullName, "src", "Web", "Grand.Web.Admin", "Areas", "Admin", "Views", "AuditLog"), + "*.cshtml"); + + Assert.IsTrue(views.Length >= 3); + foreach (var view in views) + { + var source = File.ReadAllText(view); + Assert.IsFalse(source.Contains("Html.Raw"), view); + Assert.IsFalse(source.Contains("{{{"), view); + } + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerManagementControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerManagementControllerTests.cs index f24b0b8d4b..8d4465e545 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerManagementControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerManagementControllerTests.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using Grand.Web.Common.Audit; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; @@ -44,6 +45,7 @@ public void CheckTwoFactorEnabledWarningPublic(Customer existingCustomer, Custom private Mock _groupServiceMock; private Mock _permissionServiceMock; private Mock _contextAccessorMock; + private Mock _auditEventRecorderMock = new(); [TestInitialize] public void Setup() @@ -88,7 +90,7 @@ public async Task Impersonate_NoPermission_ErrorsAndRedirectsToEdit_NoUserFieldU _customerServiceMock.Setup(s => s.GetCustomerById("c1")).ReturnsAsync(target); _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.AllowCustomerImpersonation)).ReturnsAsync(false); - var result = await _controller.Impersonate("c1"); + var result = await _controller.Impersonate("c1", _auditEventRecorderMock.Object); var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); @@ -97,6 +99,18 @@ public async Task Impersonate_NoPermission_ErrorsAndRedirectsToEdit_NoUserFieldU SystemCustomerFieldNames.ImpersonatedCustomerId, It.IsAny()), Times.Never); } + [TestMethod] + public async Task Impersonate_Allowed_RecordsImpersonationStart() + { + var target = new Customer { Id = "c1" }; + _customerServiceMock.Setup(s => s.GetCustomerById("c1")).ReturnsAsync(target); + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.AllowCustomerImpersonation)).ReturnsAsync(true); + + await _controller.Impersonate("c1", _auditEventRecorderMock.Object); + + _auditEventRecorderMock.Verify(r => r.ImpersonationStart(target), Times.Once); + } + [TestMethod] public async Task Impersonate_NonAdminImpersonatingAdmin_Denied() { @@ -106,7 +120,7 @@ public async Task Impersonate_NonAdminImpersonatingAdmin_Denied() _groupServiceMock.Setup(g => g.IsAdmin(It.Is(c => c.Id == "admin-self"))).ReturnsAsync(false); _groupServiceMock.Setup(g => g.IsAdmin(target)).ReturnsAsync(true); - var result = await _controller.Impersonate("c1"); + var result = await _controller.Impersonate("c1", _auditEventRecorderMock.Object); var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs index fb0ec3e74d..d9a7b3ed35 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using Grand.Web.Common.Audit; using Grand.Web.Common.Localization; namespace Grand.Web.Admin.Tests.Controllers; @@ -149,12 +150,23 @@ public async Task Logout_SignsOutAndRedirectsToSubclassRoute() var providerSignOut = new Mock(); - var result = (RedirectToRouteResult)await controller.Logout(providerSignOut.Object); + var result = (RedirectToRouteResult)await controller.Logout(providerSignOut.Object, new Mock().Object); providerSignOut.Verify(a => a.SignOut(), Times.Once); Assert.AreEqual("VendorLogin", result.RouteName); } + [TestMethod] + public async Task Logout_RecordsSignOut() + { + var controller = CreateController("Admin.Address.SelectState", "VendorLogin"); + var recorder = new Mock(); + + await controller.Logout(new Mock().Object, recorder.Object); + + recorder.Verify(r => r.SignOutCurrent(), Times.Once); + } + [TestMethod] public async Task Logout_ProviderUrl_RedirectsToProvider() { @@ -162,7 +174,7 @@ public async Task Logout_ProviderUrl_RedirectsToProvider() var providerSignOut = new Mock(); providerSignOut.Setup(a => a.SignOut()).ReturnsAsync("https://idp.example/logout"); - var result = (RedirectResult)await controller.Logout(providerSignOut.Object); + var result = (RedirectResult)await controller.Logout(providerSignOut.Object, new Mock().Object); Assert.AreEqual("https://idp.example/logout", result.Url); } diff --git a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs new file mode 100644 index 0000000000..6c9219c4df --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs @@ -0,0 +1,256 @@ +using Grand.Business.Core.Events.Customers; +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Audit; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Audit; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Audit; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; + +namespace Grand.Web.Common.Tests.Audit; + +[TestClass] +public class AuditEventRecorderTests +{ + private static readonly Customer Staff = new() { Id = "staff1", Email = "anna@shop.com", StaffStoreId = "s1" }; + private static readonly Customer Buyer = new() { Id = "cust9", Email = "buyer@x.com" }; + + private Mock _auditService; + private Mock _groupService; + private Mock _workContext; + private AuditConfig _config; + private DefaultHttpContext _httpContext; + private List _recorded; + private AuditEventRecorder _recorder; + + [TestInitialize] + public void Init() + { + _recorded = []; + _auditService = new Mock(); + _auditService.Setup(s => s.InsertEvent(It.IsAny())) + .Callback(e => _recorded.Add(e)) + .Returns(Task.CompletedTask); + _groupService = new Mock(); + _groupService.Setup(g => g.IsAdmin(It.Is(c => c.Id == Staff.Id))).ReturnsAsync(true); + _workContext = new Mock(); + _workContext.SetupGet(w => w.CurrentCustomer).Returns(Staff); + var contextAccessor = new Mock(); + contextAccessor.SetupGet(c => c.WorkContext).Returns(_workContext.Object); + _config = new AuditConfig(); + _httpContext = new DefaultHttpContext { TraceIdentifier = "trace-1" }; + _httpContext.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1"); + _httpContext.Request.Headers.UserAgent = "UA"; + _httpContext.Request.RouteValues["area"] = "Admin"; + _httpContext.Request.RouteValues["controller"] = "Customer"; + _httpContext.Request.RouteValues["action"] = "ExportExcelAll"; + _recorder = new AuditEventRecorder(_auditService.Object, _config, _groupService.Object, + new HttpContextAccessor { HttpContext = _httpContext }, contextAccessor.Object, + NullLogger.Instance); + } + + [TestMethod] + public async Task SignIn_Staff_Recorded() + { + await _recorder.SignIn(Staff); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.SignIn, entry.Kind); + Assert.AreEqual(AuditSource.Admin, entry.Source); + Assert.AreEqual("staff1", entry.ActorCustomerId); + Assert.AreEqual("anna@shop.com", entry.ActorEmail); + Assert.AreEqual("s1", entry.StoreId); + Assert.AreEqual("10.0.0.1", entry.IpAddress); + Assert.AreEqual("UA", entry.UserAgent); + Assert.AreEqual("trace-1", entry.OperationId); + Assert.AreEqual("Customer", entry.EntityType); + Assert.AreEqual("staff1", entry.EntityId); + } + + [TestMethod] + public async Task SignIn_Storefront_SourceStorefront() + { + _httpContext.Request.RouteValues.Remove("area"); + + await _recorder.SignIn(Staff); + + Assert.AreEqual(AuditSource.Storefront, _recorded.Single().Source); + } + + [TestMethod] + public async Task AccessDenied_RecordsPermissionAndAction() + { + await _recorder.AccessDenied("ManagePlugins", "Edit"); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.AccessDenied, entry.Kind); + Assert.AreEqual("staff1", entry.ActorCustomerId); + Assert.AreEqual("permission: ManagePlugins / Edit", entry.Details); + } + + [TestMethod] + public async Task Plugin_RecordsNameAndChange() + { + await _recorder.Plugin("Payments.StripeCheckout", "installed"); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.Plugin, entry.Kind); + Assert.AreEqual("Plugin", entry.EntityType); + Assert.AreEqual("Payments.StripeCheckout", entry.EntityName); + Assert.AreEqual("installed", entry.Details); + } + + [TestMethod] + public async Task Event_MarkedEndpoint_SourceFromMark() + { + //an API or SCIM request: its data changes and its events must agree on the source + _httpContext.Request.RouteValues.Remove("area"); + _httpContext.SetEndpoint(new Endpoint(null, + new EndpointMetadataCollection(new AuditSourceAttribute(AuditSource.Api)), "api")); + + await _recorder.Export("Product", null, null); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditSource.Api, entry.Source); + Assert.AreEqual("Api / Customer / ExportExcelAll", entry.Action); + } + + [TestMethod] + public async Task SignIn_Customer_NotRecorded() + { + await _recorder.SignIn(Buyer); + + Assert.AreEqual(0, _recorded.Count); + } + + [TestMethod] + public async Task SignIn_SettingOff_NotRecorded() + { + _config.LogStaffSignIns = false; + + await _recorder.SignIn(Staff); + await _recorder.SignInFailed(Staff, "wrong password"); + await _recorder.SignOut(Staff); + + Assert.AreEqual(0, _recorded.Count); + } + + [TestMethod] + public async Task SignInFailed_Customer_NotRecorded() + { + await _recorder.SignInFailed(Buyer, "wrong password"); + + Assert.AreEqual(0, _recorded.Count); + } + + [TestMethod] + public async Task SignInFailed_Staff_RecordedWithReason() + { + await _recorder.SignInFailed(Staff, "wrong password"); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.SignInFailed, entry.Kind); + Assert.AreEqual("wrong password", entry.Details); + Assert.AreEqual("staff1", entry.ActorCustomerId); + } + + [TestMethod] + public async Task SignOut_Staff_Recorded() + { + await _recorder.SignOut(Staff); + + Assert.AreEqual(AuditKind.SignOut, _recorded.Single().Kind); + } + + [TestMethod] + public async Task SignOutCurrent_RecordsCurrentStaff() + { + await _recorder.SignOutCurrent(); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.SignOut, entry.Kind); + Assert.AreEqual("staff1", entry.ActorCustomerId); + } + + [TestMethod] + public async Task Export_RecordsNameFilterCount() + { + await _recorder.Export("Customer", "group=VIP", 1240); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.Export, entry.Kind); + Assert.AreEqual("Customer", entry.EntityType); + Assert.AreEqual("staff1", entry.ActorCustomerId); + StringAssert.Contains(entry.Details, "group=VIP"); + StringAssert.Contains(entry.Details, "1240"); + Assert.AreEqual("Admin / Customer / ExportExcelAll", entry.Action); + } + + [TestMethod] + public async Task ImpersonationStart_SetsImpersonatedId() + { + await _recorder.ImpersonationStart(Buyer); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.ImpersonationStart, entry.Kind); + Assert.AreEqual("staff1", entry.ActorCustomerId); + Assert.AreEqual("cust9", entry.ImpersonatedCustomerId); + Assert.AreEqual("cust9", entry.EntityId); + } + + [TestMethod] + public async Task ImpersonationEnd_ActorIsOriginal() + { + _httpContext.Request.RouteValues.Remove("area"); + _workContext.SetupGet(w => w.CurrentCustomer).Returns(Buyer); + _workContext.SetupGet(w => w.OriginalCustomerIfImpersonated).Returns(Staff); + + await _recorder.ImpersonationEnd(Buyer); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.ImpersonationEnd, entry.Kind); + Assert.AreEqual("staff1", entry.ActorCustomerId); + Assert.AreEqual("cust9", entry.ImpersonatedCustomerId); + } + + [TestMethod] + public async Task AuditLogViewed_And_Exported_Recorded() + { + await _recorder.AuditLogViewed(); + await _recorder.AuditLogExported("entity type=Product", 12); + + CollectionAssert.AreEqual(new[] { AuditKind.AuditLogViewed, AuditKind.AuditLogExported }, + _recorded.Select(e => e.Kind).ToArray()); + StringAssert.Contains(_recorded[1].Details, "12"); + } + + [TestMethod] + public async Task InsertThrows_Swallowed() + { + _auditService.Setup(s => s.InsertEvent(It.IsAny())).ThrowsAsync(new Exception("db down")); + + await _recorder.SignIn(Staff); + await _recorder.Export("Customer", null, null); + } + + [TestMethod] + public async Task SignInEventHandler_ForwardsEvents() + { + var recorder = new Mock(); + var handler = new AuditSignInEventHandler(recorder.Object); + + await handler.Handle(new CustomerLoggedInEvent(Staff, null, "s1", null), CancellationToken.None); + await handler.Handle(new CustomerLoginFailedEvent(Staff, "wrong two-factor code"), CancellationToken.None); + await handler.Handle(new CustomerLoggedOutEvent(Staff), CancellationToken.None); + + recorder.Verify(r => r.SignIn(Staff)); + recorder.Verify(r => r.SignInFailed(Staff, "wrong two-factor code")); + recorder.Verify(r => r.SignOut(Staff)); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs b/src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs new file mode 100644 index 0000000000..777af00391 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs @@ -0,0 +1,103 @@ +using Grand.Web.Common.Audit; +using Grand.Web.Common.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Filters; + +[TestClass] +public class AuditExportAttributeTests +{ + private Mock _recorder; + + [TestInitialize] + public void Setup() + { + _recorder = new Mock(); + } + + private async Task Run(IActionResult result, Dictionary arguments, Exception exception = null) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var executing = new ActionExecutingContext(actionContext, new List(), arguments, null); + var filter = new AuditExportAttribute.AuditExportFilter("Customer", _recorder.Object); + + await filter.OnActionExecutionAsync(executing, () => Task.FromResult( + new ActionExecutedContext(actionContext, new List(), null) { + Result = result, Exception = exception + })); + } + + [TestMethod] + public async Task FileResult_RecordsExportWithFilterSummary() + { + var model = new ExportFilter { SearchEmail = "vip", CustomerGroups = ["g1", "g2"], Password = "x" }; + + await Run(new FileContentResult([1, 2], "text/csv"), + new Dictionary { { "model", model }, { "selectedIds", "a,b,c" } }); + + _recorder.Verify(r => r.Export("Customer", + It.Is(s => s.Contains("SearchEmail=vip") && s.Contains("CustomerGroups: 2 items") + && s.Contains("selectedIds=a,b,c") && !s.Contains("Password")), + null), Times.Once); + } + + [TestMethod] + public async Task NonFileResult_NotRecorded() + { + await Run(new RedirectToActionResult("List", null, null), new Dictionary()); + + _recorder.Verify(r => r.Export(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ActionFailed_NotRecorded() + { + await Run(null, new Dictionary(), new InvalidOperationException()); + + _recorder.Verify(r => r.Export(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task UnreadableModel_ExportStillRecorded() + { + await Run(new FileContentResult([1], "text/csv"), + new Dictionary { { "model", new UnreadableFilter() } }); + + _recorder.Verify(r => r.Export("Customer", "unreadable", null), Times.Once); + } + + [TestMethod] + public async Task IndexerProperty_Skipped() + { + await Run(new FileContentResult([1], "text/csv"), + new Dictionary { { "model", new IndexedFilter { Name = "vip" } } }); + + _recorder.Verify(r => r.Export("Customer", "Name=vip", null), Times.Once); + } + + public class UnreadableFilter + { + public string Broken => throw new InvalidOperationException(); + } + + public class IndexedFilter + { + public string Name { get; set; } + public string this[int index] => Name; + } + + public class ExportFilter + { + public string SearchEmail { get; set; } + public string SearchName { get; set; } + public IList CustomerGroups { get; set; } + public string Password { get; set; } + public int PageSize { get; set; } + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs b/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs new file mode 100644 index 0000000000..387ac7c546 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs @@ -0,0 +1,204 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Audit; +using Grand.Web.Common.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Net; +using System.Security.Claims; + +namespace Grand.Web.Common.Tests.Filters; + +[TestClass] +public class AuditScopeFilterTests +{ + private Mock _tracker; + private Mock _workContext; + private Mock _contextAccessor; + private AuditActor _activated; + + private static readonly Customer Staff = new() { + Id = "staff1", Email = "anna@shop.com", StaffStoreId = "s1", VendorId = "v1" + }; + + [TestInitialize] + public void Setup() + { + _tracker = new Mock(); + _tracker.Setup(t => t.Activate(It.IsAny())).Callback(a => _activated = a); + _workContext = new Mock(); + _workContext.SetupGet(w => w.CurrentCustomer).Returns(Staff); + _contextAccessor = new Mock(); + _contextAccessor.SetupGet(c => c.WorkContext).Returns(_workContext.Object); + } + + private async Task Run(string area, bool authenticated = true, object[] metadata = null, bool throws = false) + { + var httpContext = new DefaultHttpContext { + TraceIdentifier = "trace-1", + User = authenticated + ? new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "anna@shop.com")], "test")) + : new ClaimsPrincipal(new ClaimsIdentity()) + }; + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1"); + httpContext.Request.Headers.UserAgent = "UA"; + var routeData = new RouteData(); + if (area != null) + routeData.Values["area"] = area; + routeData.Values["controller"] = "Order"; + routeData.Values["action"] = "CancelOrder"; + var descriptor = new ActionDescriptor { EndpointMetadata = metadata ?? [] }; + var actionContext = new ActionContext(httpContext, routeData, descriptor); + var executing = new ResourceExecutingContext(actionContext, new List(), + new List()); + var filter = new AuditScopeFilter(_tracker.Object, _contextAccessor.Object); + + await filter.OnResourceExecutionAsync(executing, () => + { + if (throws) + throw new InvalidOperationException("action failed"); + return Task.FromResult(new ResourceExecutedContext(actionContext, new List())); + }); + } + + [TestMethod] + public async Task StorefrontRequest_NotActivated() + { + await Run(null); + + _tracker.Verify(t => t.Activate(It.IsAny()), Times.Never); + _tracker.Verify(t => t.CompleteAsync(), Times.Never); + } + + [TestMethod] + public async Task AdminArea_Authenticated_ActivatedWithActor() + { + await Run("Admin"); + + Assert.IsNotNull(_activated); + Assert.AreEqual(AuditSource.Admin, _activated.Source); + Assert.AreEqual("staff1", _activated.CustomerId); + Assert.AreEqual("anna@shop.com", _activated.Email); + Assert.AreEqual("s1", _activated.StoreId); + Assert.AreEqual("v1", _activated.VendorId); + Assert.AreEqual("10.0.0.1", _activated.IpAddress); + Assert.AreEqual("UA", _activated.UserAgent); + Assert.AreEqual("trace-1", _activated.OperationId); + Assert.IsNull(_activated.ImpersonatedCustomerId); + _tracker.Verify(t => t.CompleteAsync(), Times.Once); + } + + [TestMethod] + public async Task Actor_ActionIsAreaControllerAction() + { + await Run("Admin"); + + Assert.AreEqual("Admin / Order / CancelOrder", _activated.Action); + } + + [TestMethod] + public async Task StoreArea_SourceStore() + { + await Run("Store"); + + Assert.AreEqual(AuditSource.Store, _activated.Source); + } + + [TestMethod] + public async Task VendorArea_SourceVendor() + { + await Run("Vendor"); + + Assert.AreEqual(AuditSource.Vendor, _activated.Source); + } + + [TestMethod] + public async Task AdminArea_Anonymous_NotActivated() + { + await Run("Admin", false); + + _tracker.Verify(t => t.Activate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ApiAttribute_SourceApi() + { + await Run(null, metadata: [new AuditSourceAttribute(AuditSource.Api)]); + + Assert.AreEqual(AuditSource.Api, _activated.Source); + Assert.AreEqual("Api / Order / CancelOrder", _activated.Action); + } + + [TestMethod] + public async Task ScimWithoutCustomer_ActorScim() + { + _workContext.SetupGet(w => w.CurrentCustomer).Returns(new Customer { Id = "guest" }); + + //SCIM checks its bearer token in ScimAuthorizeFilter, not through an authentication scheme + await Run(null, false, [new AuditSourceAttribute(AuditSource.Scim)]); + + Assert.AreEqual(AuditSource.Scim, _activated.Source); + Assert.AreEqual("SCIM", _activated.Email); + Assert.IsNull(_activated.CustomerId); + } + + [TestMethod] + public async Task ApiAttribute_Anonymous_NotActivated() + { + await Run(null, false, [new AuditSourceAttribute(AuditSource.Api)]); + + _tracker.Verify(t => t.Activate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ActionThrows_FlushStillCalled() + { + await Assert.ThrowsExactlyAsync(() => Run("Admin", throws: true)); + + _tracker.Verify(t => t.CompleteAsync(), Times.Once); + } + + [TestMethod] + public async Task Impersonating_ActorIsOriginal_ImpersonatedIdSet() + { + _workContext.SetupGet(w => w.CurrentCustomer).Returns(new Customer { Id = "cust9", Email = "buyer@x.com" }); + _workContext.SetupGet(w => w.OriginalCustomerIfImpersonated).Returns(Staff); + + await Run("Admin"); + + Assert.AreEqual("staff1", _activated.CustomerId); + Assert.AreEqual("anna@shop.com", _activated.Email); + Assert.AreEqual("cust9", _activated.ImpersonatedCustomerId); + } + + [TestMethod] + public async Task Storefront_Impersonating_ActivatedSourceStorefront() + { + _workContext.SetupGet(w => w.CurrentCustomer).Returns(new Customer { Id = "cust9", Email = "buyer@x.com" }); + _workContext.SetupGet(w => w.OriginalCustomerIfImpersonated).Returns(Staff); + + await Run(null); + + Assert.AreEqual(AuditSource.Storefront, _activated.Source); + Assert.AreEqual("staff1", _activated.CustomerId); + Assert.AreEqual("cust9", _activated.ImpersonatedCustomerId); + } + + [TestMethod] + public async Task Storefront_NotImpersonating_NotActivated() + { + _workContext.SetupGet(w => w.CurrentCustomer).Returns(new Customer { Id = "cust9", Email = "buyer@x.com" }); + + await Run(null); + + _tracker.Verify(t => t.Activate(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/Authorization/PermissionAuthorizeAttributeTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/Authorization/PermissionAuthorizeAttributeTests.cs new file mode 100644 index 0000000000..c885ccb3de --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/Authorization/PermissionAuthorizeAttributeTests.cs @@ -0,0 +1,72 @@ +using Grand.Domain.Permissions; +using Grand.Web.Common.Audit; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.Authorization; + +[TestClass] +public class PermissionAuthorizeAttributeTests +{ + private Mock _permissionService; + private Mock _recorder; + + [TestInitialize] + public void Setup() + { + _permissionService = new Mock(); + _recorder = new Mock(); + } + + private async Task Run() + { + var services = new ServiceCollection() + .AddSingleton(_permissionService.Object) + .AddSingleton(_recorder.Object) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext { RequestServices = services }; + var context = new AuthorizationFilterContext( + new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), new List()); + + await new PermissionAuthorizeAttribute(PermissionSystemName.Plugins).OnAuthorizationAsync(context); + return context; + } + + [TestMethod] + public async Task StaffWithoutPermission_AccessDeniedRecorded() + { + _permissionService.Setup(p => p.Authorize(StandardPermission.ManageAccessAdminPanel)).ReturnsAsync(true); + + var context = await Run(); + + Assert.IsInstanceOfType(context.Result); + _recorder.Verify(r => r.AccessDenied(PermissionSystemName.Plugins, null), Times.Once); + } + + [TestMethod] + public async Task NotStaff_SentToLogin_NotRecorded() + { + var context = await Run(); + + Assert.IsInstanceOfType(context.Result); + _recorder.Verify(r => r.AccessDenied(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Permitted_NotRecorded() + { + _permissionService.Setup(p => p.Authorize(PermissionSystemName.Plugins)).ReturnsAsync(true); + + var context = await Run(); + + Assert.IsNull(context.Result); + _recorder.Verify(r => r.AccessDenied(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/TagHelpers/AuditHistoryTagHelperTests.cs b/src/Tests/Grand.Web.Common.Tests/TagHelpers/AuditHistoryTagHelperTests.cs new file mode 100644 index 0000000000..6fd5866536 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/TagHelpers/AuditHistoryTagHelperTests.cs @@ -0,0 +1,99 @@ +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Common.TagHelpers.Admin; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Text.Encodings.Web; + +namespace Grand.Web.Common.Tests.TagHelpers; + +[TestClass] +public class AuditHistoryTagHelperTests +{ + private Mock _permissionService; + + [TestInitialize] + public void Setup() + { + _permissionService = new Mock(); + _permissionService.Setup(p => p.Authorize(PermissionSystemName.AuditLog)).ReturnsAsync(true); + } + + private async Task Render(string area, string entityType, string entityId, string entityNames = null) + { + var routeData = new RouteData(); + if (area != null) + routeData.Values["area"] = area; + var helper = new AuditHistoryTagHelper(_permissionService.Object, new Mock().Object, + new Mock().Object) { + ViewContext = new ViewContext { HttpContext = new DefaultHttpContext(), RouteData = routeData }, + EntityType = entityType, + EntityId = entityId, + EntityNames = entityNames + }; + var output = new TagHelperOutput("audit-history", [], + (_, _) => Task.FromResult(new DefaultTagHelperContent())); + + await helper.ProcessAsync(new TagHelperContext([], new Dictionary(), "id"), output); + + await using var writer = new StringWriter(); + output.WriteTo(writer, HtmlEncoder.Default); + return writer.ToString(); + } + + [TestMethod] + public async Task NotAdminArea_RendersNothing() + { + Assert.AreEqual("", await Render("Store", "Product", "p1")); + Assert.AreEqual("", await Render(null, "Product", "p1")); + } + + [TestMethod] + public async Task NoPermission_RendersNothing() + { + _permissionService.Setup(p => p.Authorize(PermissionSystemName.AuditLog)).ReturnsAsync(false); + + Assert.AreEqual("", await Render("Admin", "Product", "p1")); + } + + [TestMethod] + public async Task Admin_RendersLinkWithTypeAndId() + { + var html = await Render("Admin", "Product", "p1"); + + StringAssert.Contains(html, "/Admin/AuditLog/HistoryPopup?entityType=Product&entityId=p1"); + StringAssert.Contains(html, "bi-clock-history"); + StringAssert.Contains(html, "Change history"); + StringAssert.Contains(html, "magnificPopup"); + } + + [TestMethod] + public async Task TypeOnly_LinkWithoutId() + { + var html = await Render("Admin", "SalesEmployee", null); + + StringAssert.Contains(html, "/Admin/AuditLog/HistoryPopup?entityType=SalesEmployee\""); + } + + [TestMethod] + public async Task EntityNames_AddedToTheLink() + { + var html = await Render("Admin", "Setting", null, "catalogsettings,ordersettings"); + + StringAssert.Contains(html, "HistoryPopup?entityType=Setting&entityNames=catalogsettings%2Cordersettings"); + } + + [TestMethod] + public async Task EntityIdIsUrlEncoded() + { + var html = await Render("Admin", "Product", "a\"> diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml new file mode 100644 index 0000000000..d83f3bb7f4 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml @@ -0,0 +1,81 @@ +@model Grand.Web.Admin.Models.AuditLog.AuditLogListModel +@{ + ViewBag.Title = Loc["Admin.System.AuditLog"]; +} + +@* The list shape without Add new: no primary action, Export CSV as an outline button in the header, + the actor e-mail as the quick search with the other criteria behind the Filters toggle, and the + grid in a flush card. The export posts the same filter form the grid reads. *@ + + + + + +
+ + + + +
+ + +
+ + + + + + +
+
+ + + + + + {{ CreatedOn }} + + + + + + + + + +
+ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Campaign/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Campaign/Edit.cshtml index 3ac4b4c5cd..cc6f3fd25c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Campaign/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Campaign/Edit.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["admin.marketing.Campaigns.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Country/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Country/Edit.cshtml index d216a42c0c..414838b296 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Country/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Country/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List", "Country", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Countries.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Edit.cshtml index 94ee031134..93c1d1085f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Edit.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Courses.Course.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/EditLesson.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/EditLesson.cshtml index 6784e929c6..feeaca1053 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/EditLesson.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/EditLesson.cshtml @@ -15,6 +15,7 @@ back-url="@Url.Action("Edit", "Course", new { Id = Model.CourseId })" back-text="@Loc["Admin.Courses.Course.BackToCourse"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Level.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Level.cshtml index cd71b37f55..088b4170c2 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Level.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/Level.cshtml @@ -5,6 +5,9 @@ @* an inline-edit grid in a flush card; grid.js moves its Add new into the card header *@ + + + + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Edit.cshtml index db634fc804..91ce5a210a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Customer/Edit.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Customers.Customers.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/CustomerTag/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/CustomerTag/Edit.cshtml index 0409f9f3a6..32b2250c34 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/CustomerTag/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/CustomerTag/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Customers.CustomerTags.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditDocument.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditDocument.cshtml index bcfe1c4e7e..719bb9a79d 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditDocument.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditDocument.cshtml @@ -32,6 +32,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Documents.Document.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditType.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditType.cshtml index 6766dc0630..cd7febf6f8 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditType.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Document/EditType.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("Types")" back-text="@Loc["Admin.Documents.Type.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditArticle.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditArticle.cshtml index b8e64dc5a2..bae87c5186 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditArticle.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditArticle.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Content.Knowledgebase.KnowgledgebaseArticle.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditCategory.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditCategory.cshtml index 9e6e0d40a9..bb4f6d8947 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditCategory.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Knowledgebase/EditCategory.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Content.Knowledgebase.KnowgledgebaseCategory.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Edit.cshtml index 52dd4185f6..1bad4bb6d0 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Language/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List", "Language", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Languages.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/BrandLayouts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/BrandLayouts.cshtml index 305d785b45..49e41d9d7b 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/BrandLayouts.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/BrandLayouts.cshtml @@ -6,6 +6,7 @@ @* the grid edits inline; its Add new moves into the card header (grid.js) *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CategoryLayouts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CategoryLayouts.cshtml index c767625e73..7516f38272 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CategoryLayouts.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CategoryLayouts.cshtml @@ -6,6 +6,7 @@ @* the grid edits inline; its Add new moves into the card header (grid.js) *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CollectionLayouts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CollectionLayouts.cshtml index 9a5208adf4..fd5d2f0dc1 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CollectionLayouts.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/CollectionLayouts.cshtml @@ -6,6 +6,7 @@ @* the grid edits inline; its Add new moves into the card header (grid.js) *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/PageLayouts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/PageLayouts.cshtml index 284253fd0a..e12ab155f9 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/PageLayouts.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/PageLayouts.cshtml @@ -6,6 +6,7 @@ @* the grid edits inline; its Add new moves into the card header (grid.js) *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/ProductLayouts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/ProductLayouts.cshtml index ee42e7803c..2dbd9855cb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/ProductLayouts.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Layout/ProductLayouts.cshtml @@ -6,6 +6,7 @@ @* the grid edits inline; its Add new moves into the card header (grid.js) *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Menu/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Menu/Edit.cshtml index 89ab03e2c8..7583c6bbe6 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Menu/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Menu/Edit.cshtml @@ -24,6 +24,7 @@ back-url="@backUrl" back-text="@backText"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/NewsLetterSubscription/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/NewsLetterSubscription/List.cshtml index a1be73eddd..63240fce28 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/NewsLetterSubscription/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/NewsLetterSubscription/List.cshtml @@ -12,6 +12,7 @@ so Search is the one primary action. The ids the script below looks up are unchanged. *@ + + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/OrderStatus/Index.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/OrderStatus/Index.cshtml index e8808c967a..3e33cab350 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/OrderStatus/Index.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/OrderStatus/Index.cshtml @@ -6,6 +6,9 @@ @* The inline grid in a flush card: grid.js moves its Add new into the card header, which makes it the page's only add; there is nothing else to save. *@ + + + + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductTags/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductTags/Edit.cshtml index 54df1224f1..3bf21f98ac 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductTags/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductTags/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Catalog.ProductTags.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/SalesEmployee/Index.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/SalesEmployee/Index.cshtml index 8d16cd820c..c20601be43 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/SalesEmployee/Index.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/SalesEmployee/Index.cshtml @@ -6,6 +6,9 @@ @* The inline grid in a flush card: grid.js moves its Add new into the card header, which makes it the page's only add; there is nothing else to save. *@ + + + + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/AdminSearch.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/AdminSearch.cshtml index e9c5165e86..c382a26786 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/AdminSearch.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/AdminSearch.cshtml @@ -9,6 +9,7 @@ limits on the term and on the results *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Catalog.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Catalog.cshtml index 42c96eca30..b3760f135c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Catalog.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Catalog.cshtml @@ -9,6 +9,7 @@ @* the store scope, then one tab per settings group; Save commits every tab *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Content.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Content.cshtml index b87e3a4302..a83d57f23d 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Content.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Content.cshtml @@ -10,6 +10,7 @@ @* the store scope, then one tab per settings group; Save commits every tab *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.cshtml index c71e0309cf..544c06c6fc 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Customer.cshtml @@ -10,6 +10,7 @@ @* the store scope, then one tab per settings group; Save commits every tab *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/GeneralCommon.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/GeneralCommon.cshtml index 93d7dcf79a..fe6e053d54 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/GeneralCommon.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/GeneralCommon.cshtml @@ -8,6 +8,7 @@ @* the store scope, then one tab per settings group; Save commits every tab *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Media.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Media.cshtml index 0df1a7d798..5d980df103 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Media.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Media.cshtml @@ -10,6 +10,7 @@ stored and what the file manager allows *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnActionEdit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnActionEdit.cshtml index f0f168900a..d7734fc555 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnActionEdit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnActionEdit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("MerchandiseReturnActionList", "Setting", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Settings.Order.MerchandiseReturnActions.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnReasonEdit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnReasonEdit.cshtml index 44d2159b05..317a3eafe1 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnReasonEdit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/MerchandiseReturnReasonEdit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("MerchandiseReturnReasonList", "Setting", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Settings.Order.MerchandiseReturnReasons.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/PushNotifications.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/PushNotifications.cshtml index a9b10852e3..3d4e6a1cc4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/PushNotifications.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/PushNotifications.cshtml @@ -9,6 +9,7 @@ the Firebase project they are sent through *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Sales.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Sales.cshtml index 6697bd752f..32459d2343 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Sales.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Sales.cshtml @@ -10,6 +10,7 @@ @* the store scope, then one tab per settings group; Save commits every tab *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/SystemSetting.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/SystemSetting.cshtml index 8f733be889..c2dad223f6 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/SystemSetting.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/SystemSetting.cshtml @@ -22,6 +22,7 @@ panel's own grid, language and document settings *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Vendor.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Vendor.cshtml index b153fe4986..363cc5d339 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Vendor.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Setting/Vendor.cshtml @@ -95,6 +95,7 @@ is required *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditDeliveryDate.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditDeliveryDate.cshtml index 0f34a7851d..dba13cc157 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditDeliveryDate.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditDeliveryDate.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("DeliveryDates", "Shipping", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Shipping.DeliveryDates.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditMethod.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditMethod.cshtml index 0febe96c5c..3f41ee8c18 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditMethod.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditMethod.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("Methods", "Shipping", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Shipping.Methods.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditPickupPoint.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditPickupPoint.cshtml index a3c1a1ff49..bc9ecf1860 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditPickupPoint.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditPickupPoint.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("PickupPoints", "Shipping", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Shipping.PickupPoints.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditWarehouse.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditWarehouse.cshtml index 49d0c1c4ca..d97dcffc6b 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditWarehouse.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Shipping/EditWarehouse.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("Warehouses", "Shipping", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Shipping.Warehouses.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Edit.cshtml index ea53efb9c4..c71b3e8696 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List", "Store", new { area = Constants.AreaAdmin })" back-text="@Loc["Admin.Configuration.Stores.BackToList"]"> + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Tax/Categories.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Tax/Categories.cshtml index 2893aa3c36..c34504fda0 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Tax/Categories.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Tax/Categories.cshtml @@ -7,6 +7,7 @@ @* the grid edits inline; its Add new moves into the card header (grid.js) *@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Vendor/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Vendor/Edit.cshtml index 2e5d17d641..871ce0068d 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Vendor/Edit.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Vendor/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Vendors.BackToList"]"> + @* activate / deactivate is a button of the header, not a menu entry: it is the vendor's state, checked often *@ @if (Model.Active) { diff --git a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs new file mode 100644 index 0000000000..7263468a5f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -0,0 +1,311 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Audit; +using Grand.Domain.Permissions; +using Grand.Web.Admin.Models.AuditLog; +using Grand.Web.Common.Audit; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using System.Text; + +namespace Grand.Web.Admin.Controllers; + +/// +/// The staff audit trail: list, entry detail and CSV export. Main Admin only; retention is set in appsettings. +/// +[PermissionAuthorize(PermissionSystemName.AuditLog)] +public class AuditLogController( + IAuditService auditService, + IAuditEventRecorder auditEventRecorder, + IStoreService storeService, + IRequestTranslationService translationService) : BaseAdminController +{ + private const int DefaultDays = 7; + private const int ChangedFieldsLength = 200; + private const int HistoryPageSize = 20; + private const int TypeHistorySize = 100; + private const int HistoryValueLength = 100; + + private static readonly string[] CsvColumns = [ + "CreatedOnUtc", "Kind", "Source", "ActorEmail", "ActorCustomerId", "ImpersonatedCustomerId", "StoreId", + "VendorId", "IpAddress", "UserAgent", "Action", "OperationId", "EntityType", "EntityId", "EntityName", + "EntityStoreId", + "Path", "OldValue", "NewValue", "Masked", "Truncated", "Details" + ]; + + public IActionResult Index() + { + return RedirectToAction("List"); + } + + public async Task List(string entityType, string entityId, string operationId) + { + var model = new AuditLogListModel { + EntityType = entityType, + EntityId = entityId, + OperationId = operationId, + //a link to one record or one operation shows its whole history + FromUtc = string.IsNullOrEmpty(entityId) && string.IsNullOrEmpty(operationId) + ? DateTime.UtcNow.AddDays(-DefaultDays) + : null + }; + await PrepareLists(model); + await auditEventRecorder.AuditLogViewed(); + return View(model); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task List(DataSourceRequest command, AuditLogListModel model) + { + var entries = await auditService.GetEntries(ToSearch(model), command.Page - 1, command.PageSize); + var stores = await StoreNames(); + return Json(new DataSourceResult { + Data = entries.Select(e => ToRow(e, stores, false)).ToList(), + Total = entries.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Detail(string id) + { + var entry = await auditService.GetById(id); + if (entry == null) + return NotFound(); + + return View(new AuditEntryModel { + Entry = entry, + Source = SourceName(entry.Source), + Kind = KindName(entry.Kind), + AppliesTo = AppliesTo(entry, await StoreNames()), + Changes = entry.Changes.Select(ToChange).ToList() + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Export)] + public async Task ExportCsv(AuditLogListModel model) + { + Response.ContentType = "text/csv; charset=utf-8"; + Response.Headers.ContentDisposition = $"attachment; filename=auditlog-{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; + + var count = 0; + var complete = false; + try + { + await using (var writer = new StreamWriter(Response.Body, new UTF8Encoding(true), 4096, true)) + { + writer.NewLine = "\r\n"; + await writer.WriteLineAsync(string.Join(",", CsvColumns)); + await foreach (var entry in auditService.StreamEntries(ToSearch(model), HttpContext.RequestAborted)) + { + count++; + if (entry.Changes.Count == 0) + await writer.WriteLineAsync(CsvRow(entry, null)); + foreach (var change in entry.Changes) + await writer.WriteLineAsync(CsvRow(entry, change)); + } + } + + complete = true; + } + finally + { + //access to the log is logged, also when the download stops part-way + await auditEventRecorder.AuditLogExported( + FilterSummary(model) + (complete ? "" : "; incomplete"), count); + } + + return new EmptyResult(); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public IActionResult HistoryPopup(string entityType, string entityId, string entityNames) + { + return View(new AuditHistoryPopupModel { + EntityType = entityType, EntityId = entityId, EntityNames = entityNames + }); + } + + /// + /// The history of one record, a page at a time, or the newest entries of a whole entity type + /// + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task HistoryList(DataSourceRequest command, string entityType, string entityId, + string entityNames) + { + var search = new AuditEntrySearch { + EntityType = entityType, + EntityId = entityId, + EntityNames = entityNames?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + }; + var entries = string.IsNullOrEmpty(entityId) + ? await auditService.GetEntries(search, 0, TypeHistorySize) + : await auditService.GetEntries(search, command.Page - 1, HistoryPageSize); + var stores = await StoreNames(); + return Json(new DataSourceResult { + Data = entries.Select(e => ToRow(e, stores, true)).ToList(), + Total = string.IsNullOrEmpty(entityId) ? entries.Count : entries.TotalCount + }); + } + + private async Task PrepareLists(AuditLogListModel model) + { + var all = translationService.GetResource("Admin.Common.All"); + model.AvailableEntityTypes.Add(new SelectListItem(all, "")); + foreach (var type in await auditService.GetEntityTypes()) + model.AvailableEntityTypes.Add(new SelectListItem(type, type)); + + model.AvailableSources.Add(new SelectListItem(all, "")); + foreach (var source in Enum.GetValues()) + model.AvailableSources.Add(new SelectListItem(SourceName(source), source.ToString())); + + model.AvailableKinds.Add(new SelectListItem(all, "")); + foreach (var kind in Enum.GetValues()) + model.AvailableKinds.Add(new SelectListItem(KindName(kind), kind.ToString())); + + model.AvailableStores.Add(new SelectListItem(all, "")); + foreach (var store in await storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem(store.Shortcut ?? store.Name, store.Id)); + } + + private static AuditEntrySearch ToSearch(AuditLogListModel model) + { + return new AuditEntrySearch { + FromUtc = AsUtc(model.FromUtc), + ToUtc = AsUtc(model.ToUtc?.Date.AddDays(1).AddTicks(-1)), + ActorEmail = model.ActorEmail?.Trim(), + Source = model.Source, + Kind = model.Kind, + EntityType = model.EntityType, + EntityId = model.EntityId?.Trim(), + StoreId = model.StoreId, + OperationId = model.OperationId?.Trim() + }; + } + + //the filter fields are labelled UTC, but bind as Unspecified, which the database driver would read as server time + private static DateTime? AsUtc(DateTime? value) + { + return value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; + } + + private async Task> StoreNames() + { + return (await storeService.GetAllStores()).ToDictionary(s => s.Id, s => s.Shortcut ?? s.Name); + } + + private string AppliesTo(AuditEntry entry, IDictionary stores) + { + if (entry.EntityStoreId == null) + return null; + if (entry.EntityStoreId == "") + return translationService.GetResource("Admin.System.AuditLog.AllStores"); + return stores.TryGetValue(entry.EntityStoreId, out var name) ? name : entry.EntityStoreId; + } + + //the list shows only the changed field names; the values are for the change history popup + private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary stores, bool withValues) + { + var fields = string.Join(", ", entry.Changes.Select(c => c.Path)); + var appliesTo = AppliesTo(entry, stores); + return new AuditEntryRowModel { + Id = entry.Id, + CreatedOn = entry.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm:ss"), + ActorEmail = entry.ActorEmail, + Source = SourceName(entry.Source), + Kind = KindName(entry.Kind), + What = string.Join(" ", new[] { entry.EntityType, entry.EntityName }.Where(s => !string.IsNullOrEmpty(s))) + + (appliesTo == null ? "" : $" ({appliesTo})"), + ChangedFields = fields.Length > ChangedFieldsLength ? fields[..ChangedFieldsLength] + "…" : fields, + ChangesText = withValues + ? string.Join(" · ", entry.Changes.Select(ToChange).Select(c => + $"{c.Path}: {Shorten(c.OldValue)} → {Shorten(c.NewValue)}")) + : null + }; + } + + private static string Shorten(string value) + { + if (value == null) + return "—"; + return value.Length > HistoryValueLength ? value[..HistoryValueLength] + "…" : value; + } + + private AuditChangeModel ToChange(AuditChange change) + { + if (change.Masked) + { + var hidden = translationService.GetResource("Admin.System.AuditLog.ChangedHidden"); + return new AuditChangeModel { Path = change.Path, OldValue = hidden, NewValue = hidden }; + } + + var suffix = change.Truncated + ? $" … ({translationService.GetResource("Admin.System.AuditLog.Truncated")})" + : ""; + return new AuditChangeModel { + Path = change.Path, + OldValue = change.OldValue == null ? null : change.OldValue + suffix, + NewValue = change.NewValue == null ? null : change.NewValue + suffix + }; + } + + private string SourceName(AuditSource source) + { + return translationService.GetResource($"Enums.Grand.Domain.Audit.AuditSource.{source}"); + } + + private string KindName(AuditKind kind) + { + return translationService.GetResource($"Enums.Grand.Domain.Audit.AuditKind.{kind}"); + } + + private static string FilterSummary(AuditLogListModel model) + { + var parts = new List(); + void Add(string name, object value) + { + if (value != null && value.ToString() != "") + parts.Add($"{name}={value}"); + } + + Add("from", model.FromUtc?.ToString("yyyy-MM-dd")); + Add("to", model.ToUtc?.ToString("yyyy-MM-dd")); + Add("actor", model.ActorEmail); + Add("source", model.Source); + Add("kind", model.Kind); + Add("entity type", model.EntityType); + Add("entity id", model.EntityId); + Add("store", model.StoreId); + Add("operation", model.OperationId); + return parts.Count == 0 ? "none" : string.Join("; ", parts); + } + + private static string CsvRow(AuditEntry entry, AuditChange change) + { + string[] values = [ + entry.CreatedOnUtc.ToString("o"), entry.Kind.ToString(), entry.Source.ToString(), entry.ActorEmail, + entry.ActorCustomerId, entry.ImpersonatedCustomerId, entry.StoreId, entry.VendorId, entry.IpAddress, + entry.UserAgent, entry.Action, entry.OperationId, entry.EntityType, entry.EntityId, entry.EntityName, + entry.EntityStoreId, + change?.Path, change?.OldValue, change?.NewValue, change?.Masked.ToString(), change?.Truncated.ToString(), + entry.Details + ]; + return string.Join(",", values.Select(CsvCell)); + } + + //RFC 4180 quoting, and a leading quote mark so a spreadsheet never runs a cell as a formula + private static string CsvCell(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + if (value[0] is '=' or '+' or '-' or '@' or '\t' or '\r') + value = "'" + value; + return value.IndexOfAny([',', '"', '\r', '\n']) >= 0 + ? "\"" + value.Replace("\"", "\"\"") + "\"" + : value; + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs b/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs index 86a9920ff0..377bd9da3b 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs @@ -101,6 +101,7 @@ public async Task History(string campaignId, DataSourceRequest co } [PermissionAuthorizeAction(PermissionActionName.Export)] + [AuditExport("Campaign")] public async Task ExportCsv(string campaignId) { try diff --git a/src/Web/Grand.Web.Admin/Controllers/CountryController.cs b/src/Web/Grand.Web.Admin/Controllers/CountryController.cs index bbf07c90ba..2b5ac1b65b 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CountryController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CountryController.cs @@ -351,6 +351,7 @@ public async Task StateDelete(string id, string countryId) #region Export / import [PermissionAuthorizeAction(PermissionActionName.Export)] + [AuditExport("Country")] public async Task ExportExcel() { var countries = await _countryService.GetAllCountries(); diff --git a/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs b/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs index 86a580eab9..8502b3bdb1 100644 --- a/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs @@ -252,6 +252,7 @@ public async Task ResourceDelete(string id) #region Export / Import [PermissionAuthorizeAction(PermissionActionName.Export)] + [AuditExport("Language")] public async Task ExportXml(string id) { var language = await _languageService.GetLanguageById(id); diff --git a/src/Web/Grand.Web.Admin/Controllers/NewsLetterSubscriptionController.cs b/src/Web/Grand.Web.Admin/Controllers/NewsLetterSubscriptionController.cs index af38315326..99c5a946a2 100644 --- a/src/Web/Grand.Web.Admin/Controllers/NewsLetterSubscriptionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/NewsLetterSubscriptionController.cs @@ -6,6 +6,7 @@ using Grand.SharedKernel.Extensions; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Models.Messages; +using Grand.Web.Common.Filters; using Grand.Web.Common.DataSource; using Grand.Web.Common.Extensions; using Grand.Web.Common.Security.Authorization; @@ -170,6 +171,7 @@ public async Task SubscriptionDelete(string id) [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("NewsLetterSubscription")] public async Task ExportCsv(NewsLetterSubscriptionListModel model, string[] searchCategoryIds) { bool? isActive = null; diff --git a/src/Web/Grand.Web.Admin/Controllers/OrderController.cs b/src/Web/Grand.Web.Admin/Controllers/OrderController.cs index 5adec10c67..07c86d6681 100644 --- a/src/Web/Grand.Web.Admin/Controllers/OrderController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/OrderController.cs @@ -46,6 +46,7 @@ public class OrderController( [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Order")] public async Task ExportExcelAll(OrderListModel model) { var orders = await OrderViewModelService.PrepareOrders(model); @@ -63,6 +64,7 @@ public async Task ExportExcelAll(OrderListModel model) [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Order")] public async Task ExportExcelSelected(string selectedIds) { var orders = new List(); diff --git a/src/Web/Grand.Web.Admin/Controllers/PluginController.cs b/src/Web/Grand.Web.Admin/Controllers/PluginController.cs index 55e11536a3..3a20a9606f 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PluginController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PluginController.cs @@ -7,6 +7,7 @@ using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Models.Plugins; +using Grand.Web.Common.Audit; using Grand.Web.Common.DataSource; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; @@ -25,7 +26,8 @@ public class PluginController( IServiceProvider serviceProvider, IEnumTranslationService enumTranslationService, IWebHostEnvironment webHostEnvironment, - ExtensionsConfig extConfig) + ExtensionsConfig extConfig, + IAuditEventRecorder auditEventRecorder) : BaseAdminController { #region Utilities @@ -148,6 +150,7 @@ public async Task Install(string systemName) //install plugin var plugin = pluginInfo.Instance(serviceProvider); await plugin.Install(); + await auditEventRecorder.Plugin(systemName, "installed"); Success(translationService.GetResource("Admin.Plugins.Installed")); @@ -182,6 +185,7 @@ public async Task Uninstall(string systemName) //uninstall plugin var plugin = pluginInfo.Instance(serviceProvider); await plugin.Uninstall(); + await auditEventRecorder.Plugin(systemName, "uninstalled"); Success(translationService.GetResource("Admin.Plugins.Uninstalled")); @@ -200,7 +204,7 @@ public async Task Uninstall(string systemName) } [HttpPost] - public IActionResult Remove(string systemName) + public async Task Remove(string systemName) { if (extConfig.DisableUploadExtensions) { @@ -216,6 +220,7 @@ public IActionResult Remove(string systemName) return RedirectToAction("List"); DeleteDirectory(pluginInfo.OriginalAssemblyFile.DirectoryName); + await auditEventRecorder.Plugin(systemName, "removed"); //uninstall plugin Success(translationService.GetResource("Admin.Plugins.Removed")); @@ -246,7 +251,7 @@ public IActionResult ReloadList() [HttpPost] - public IActionResult UploadPlugin(IFormFile zippedFile) + public async Task UploadPlugin(IFormFile zippedFile) { if (extConfig.DisableUploadExtensions) { @@ -280,6 +285,7 @@ public IActionResult UploadPlugin(IFormFile zippedFile) } Upload(zipFilePath); + await auditEventRecorder.Plugin(zippedFile.FileName, "uploaded"); var message = translationService.GetResource("Admin.Plugins.Uploaded"); Success(message); diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs new file mode 100644 index 0000000000..c72544c3e6 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs @@ -0,0 +1,60 @@ +using Grand.Domain.Audit; + +namespace Grand.Web.Admin.Models.AuditLog; + +/// +/// One row of the audit log grid and of the change history popup +/// +public class AuditEntryRowModel +{ + public string Id { get; set; } + public string CreatedOn { get; set; } + public string ActorEmail { get; set; } + public string Source { get; set; } + public string Kind { get; set; } + public string What { get; set; } + public string ChangedFields { get; set; } + + /// + /// "Path: before → after" of every change, shortened, for the change history popup + /// + public string ChangesText { get; set; } +} + +public class AuditChangeModel +{ + public string Path { get; set; } + public string OldValue { get; set; } + public string NewValue { get; set; } +} + +/// +/// The entry detail screen +/// +public class AuditEntryModel +{ + public AuditEntry Entry { get; set; } + public string Source { get; set; } + public string Kind { get; set; } + + /// + /// The store the record belongs to, or "All stores"; null when the record has no store + /// + public string AppliesTo { get; set; } + + public IList Changes { get; set; } = new List(); +} + +/// +/// The change history popup of one record (EntityId set) or of one entity type +/// +public class AuditHistoryPopupModel +{ + public string EntityType { get; set; } + public string EntityId { get; set; } + + /// + /// Comma-separated entity names the popup is limited to (the setting classes of a settings page) + /// + public string EntityNames { get; set; } +} diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs new file mode 100644 index 0000000000..8310cf6d67 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs @@ -0,0 +1,45 @@ +using Grand.Domain.Audit; +using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.Models; +using Microsoft.AspNetCore.Mvc.Rendering; +using System.ComponentModel.DataAnnotations; + +namespace Grand.Web.Admin.Models.AuditLog; + +public class AuditLogListModel : BaseModel +{ + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.FromUtc")] + [UIHint("DateNullable")] + public DateTime? FromUtc { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.ToUtc")] + [UIHint("DateNullable")] + public DateTime? ToUtc { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.ActorEmail")] + public string ActorEmail { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.Source")] + public AuditSource? Source { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.Kind")] + public AuditKind? Kind { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.EntityType")] + public string EntityType { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.EntityId")] + public string EntityId { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.StoreId")] + public string StoreId { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.OperationId")] + public string OperationId { get; set; } + + public IList AvailableEntityTypes { get; set; } = new List(); + public IList AvailableSources { get; set; } = new List(); + public IList AvailableKinds { get; set; } = new List(); + public IList AvailableStores { get; set; } = new List(); + +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs index c201373d49..7e581678d6 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs @@ -249,6 +249,7 @@ public async Task PicturePopup(PictureModel model) #region Export / Import [PermissionAuthorizeAction(PermissionActionName.Export)] + [AuditExport("Brand")] public async Task ExportXlsx([FromServices] IExportManager exportManager) { try diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs index cc1f1c413e..df22038924 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs @@ -217,6 +217,7 @@ public async Task PicturePopup(PictureModel model) #region Export / Import [PermissionAuthorizeAction(PermissionActionName.Export)] + [AuditExport("Category")] public async Task ExportXlsx([FromServices] IExportManager exportManager) { try diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs index 14f6cc2a6a..c1fd035256 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -250,6 +250,7 @@ public async Task PicturePopup(PictureModel model) #region Export / Import [PermissionAuthorizeAction(PermissionActionName.Export)] + [AuditExport("Collection")] public async Task ExportXlsx([FromServices] IExportManager exportManager) { try diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs index 580e4ded12..1e8f27e17a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs @@ -10,6 +10,8 @@ using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Audit; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; using Grand.Web.Common.Localization; @@ -46,7 +48,7 @@ protected override void CheckTwoFactorEnabledWarning(Customer? existingCustomer, [PermissionAuthorizeAction(PermissionActionName.Preview)] [HttpPost] - public async Task Impersonate(string id) + public async Task Impersonate(string id, [FromServices] IAuditEventRecorder auditEventRecorder) { var (customer, denied) = await LoadAuthorizedCustomer(id); if (customer is null) return denied!; @@ -65,6 +67,7 @@ public async Task Impersonate(string id) await CustomerService.UpdateUserField(ContextAccessor.WorkContext.CurrentCustomer, SystemCustomerFieldNames.ImpersonatedCustomerId, customer.Id); + await auditEventRecorder.ImpersonationStart(customer); return RedirectToAction("Index", "Home", new { area = "" }); } @@ -94,6 +97,7 @@ public async Task DeleteSelected(ICollection selectedIds) [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Customer")] public async Task ExportExcelAll(CustomerListModel model) { var salesEmployeeId = await GroupService.IsSalesManager(ContextAccessor.WorkContext.CurrentCustomer) @@ -126,6 +130,7 @@ public async Task ExportExcelAll(CustomerListModel model) [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Customer")] public async Task ExportExcelSelected(string selectedIds) { var customers = new List(); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs index 62ec37bf1d..03a2e84102 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseHomeController.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Web.Common.Audit; using Grand.Web.Common.Controllers; using Grand.Web.Common.Security.ExternalRegistration; using Microsoft.AspNetCore.Mvc; @@ -60,8 +61,10 @@ public async Task GetStatesByCountryId(string countryId, bool? ad return Json(result); } - public async Task Logout([FromServices] IProviderSignOut providerSignOut) + public async Task Logout([FromServices] IProviderSignOut providerSignOut, + [FromServices] IAuditEventRecorder auditEventRecorder) { + await auditEventRecorder.SignOutCurrent(); var providerUrl = await providerSignOut.SignOut(); return providerUrl != null ? Redirect(providerUrl) : RedirectToRoute(LogoutRouteName); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs index 6908e35070..98f730e640 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs @@ -299,7 +299,8 @@ public async Task TwoFactorAuthorization(string token, ModelState.AddModelError("", _translationService.GetResource("Account.TwoFactorAuth.WrongSecurityCode")); } - await _mediator.Publish(new CustomerLoginFailedEvent(customer)); + await _mediator.Publish(new CustomerLoginFailedEvent(customer, + string.IsNullOrEmpty(token) ? "two-factor code missing" : "wrong two-factor code")); return View(); } } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderController.cs index 4d46da107c..10b9f0dc2d 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderController.cs @@ -6,6 +6,7 @@ using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Filters; using Grand.Web.Common.Controllers; using Grand.Web.Common.DataSource; using Grand.Web.Common.Security.Authorization; @@ -93,6 +94,7 @@ public async Task Edit(string id) return View(model); } + [AuditExport("Order")] public async Task PdfInvoice(string orderId) { var (order, denied) = await LoadAuthorizedOrder(orderId); @@ -112,6 +114,7 @@ await pdfService.PrintOrdersToPdf(stream, orders, contextAccessor.WorkContext.Wo [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Order")] public async Task PdfInvoiceAll(OrderListModel model) { var orders = await orderViewModelService.PrepareOrders(model); @@ -134,6 +137,7 @@ await pdfService.PrintOrdersToPdf(stream, orders, contextAccessor.WorkContext.Wo [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Order")] public async Task PdfInvoiceSelected(string selectedIds) { var orders = new List(); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 9ac771e039..8b5bbc758c 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -461,6 +461,7 @@ public async Task Reviews(DataSourceRequest command, string produ [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Product")] public async Task ExportExcelAll(ProductListModel model, [FromServices] IExportManager exportManager) { @@ -486,6 +487,7 @@ public async Task ExportExcelAll(ProductListModel model, [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Product")] public async Task ExportExcelSelected(string selectedIds, [FromServices] IExportManager exportManager) { diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs index a806053b4d..af5bb69678 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -451,6 +451,7 @@ public async Task EditUserFields(string id, ShipmentModel model) #region PDF export and bulk actions [PermissionAuthorizeAction(PermissionActionName.Preview)] + [AuditExport("Shipment")] public async Task PdfPackagingSlip(string shipmentId) { var (shipment, denied) = await LoadAuthorizedShipment(shipmentId); @@ -476,6 +477,7 @@ await pdfService.PrintPackagingSlipsToPdf(stream, shipments, contextAccessor.Wor [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Shipment")] public async Task PdfPackagingSlipAll(ShipmentListModel model) { if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; @@ -504,6 +506,7 @@ await pdfService.PrintPackagingSlipsToPdf(stream, shipments.shipments.ToList(), [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] + [AuditExport("Shipment")] public async Task PdfPackagingSlipSelected(string selectedIds) { var shipments = new List(); diff --git a/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs index fa46a851d8..b6bddfd7e7 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Common/LoginValidator.cs @@ -81,7 +81,7 @@ public LoginValidator( { context.AddFailure(translationService.GetResource("Account.Login.WrongCredentials")); await contextAccessor.HttpContext!.RequestServices.GetRequiredService() - .Publish(new CustomerLoginFailedEvent(customer), _); + .Publish(new CustomerLoginFailedEvent(customer, "wrong credentials"), _); } else if (await externalSignInPolicy.PasswordSignIn(customer, PanelSignInSurface.FromArea( contextAccessor.HttpContext?.GetRouteValue("area") as string)) is { Allowed: false } decision) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/AddressAttribute/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/AddressAttribute/Edit.cshtml index 1ba46665a7..489dd8a2f5 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/AddressAttribute/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/AddressAttribute/Edit.cshtml @@ -18,6 +18,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Address.AddressAttributes.BackToList"]"> + @if (!isReadOnly) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml index 51ffe3d58d..593ecba837 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Catalog.Collections.BackToList"]"> + @if (!string.IsNullOrEmpty(Model.SeName)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/EmailAccount/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/EmailAccount/Edit.cshtml index f2ad6d259d..255eff4de0 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/EmailAccount/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/EmailAccount/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Configuration.EmailAccounts.BackToList"]"> + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml index f5d1a2ce2c..ef420c70ca 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/GiftVoucher/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.GiftVouchers.BackToList"]"> + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/MerchandiseReturn/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MerchandiseReturn/Edit.cshtml index f934f3003d..566453b8f1 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/MerchandiseReturn/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MerchandiseReturn/Edit.cshtml @@ -15,6 +15,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Orders.MerchandiseReturns.BackToList"]"> + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml index 096642cee6..319ad118dc 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml @@ -15,6 +15,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Content.MessageTemplates.BackToList"]"> + @if (Model.CanCopy) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductReview/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductReview/Edit.cshtml index b23f134e3a..1417b870ab 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductReview/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductReview/Edit.cshtml @@ -13,6 +13,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Catalog.ProductReviews.BackToList"]"> + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/SpecificationAttribute/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/SpecificationAttribute/Edit.cshtml index 18961be0cb..328f6af93b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/SpecificationAttribute/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/SpecificationAttribute/Edit.cshtml @@ -14,6 +14,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Catalog.Attributes.SpecificationAttributes.BackToList"]"> + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/VendorReview/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/VendorReview/Edit.cshtml index d49e8d20e4..328f4c174b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/VendorReview/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/VendorReview/Edit.cshtml @@ -16,6 +16,7 @@ back-url="@Url.Action("List")" back-text="@Loc[$"{Scope.ResourceKeyPrefix}.VendorReviews.BackToList"]"> + diff --git a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs new file mode 100644 index 0000000000..4d566a9992 --- /dev/null +++ b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs @@ -0,0 +1,156 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Audit; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Grand.Web.Common.Audit; + +public class AuditEventRecorder( + IAuditService auditService, + AuditConfig auditConfig, + IGroupService groupService, + IHttpContextAccessor httpContextAccessor, + IContextAccessor contextAccessor, + ILogger logger) : IAuditEventRecorder +{ + public Task SignIn(Customer customer) + { + return RecordSignInEvent(AuditKind.SignIn, customer, null); + } + + public Task SignInFailed(Customer customer, string reason) + { + return RecordSignInEvent(AuditKind.SignInFailed, customer, reason); + } + + public Task SignOut(Customer customer) + { + return RecordSignInEvent(AuditKind.SignOut, customer, null); + } + + public Task SignOutCurrent() + { + return RecordSignInEvent(AuditKind.SignOut, CurrentStaff(), null); + } + + public Task Export(string entityType, string filterSummary, int? rowCount) + { + var details = string.IsNullOrEmpty(filterSummary) ? "filter: none" : $"filter: {filterSummary}"; + if (rowCount.HasValue) + details += $"; rows: {rowCount}"; + return Record(AuditKind.Export, CurrentStaff(), entry => + { + entry.EntityType = entityType; + entry.Details = details; + }); + } + + public Task ImpersonationStart(Customer target) + { + return Record(AuditKind.ImpersonationStart, CurrentStaff(), entry => Target(entry, target)); + } + + public Task ImpersonationEnd(Customer target) + { + return Record(AuditKind.ImpersonationEnd, CurrentStaff(), entry => Target(entry, target)); + } + + public Task AuditLogViewed() + { + return Record(AuditKind.AuditLogViewed, CurrentStaff(), null); + } + + public Task AuditLogExported(string filterSummary, int rowCount) + { + return Record(AuditKind.AuditLogExported, CurrentStaff(), + entry => entry.Details = $"filter: {filterSummary}; rows: {rowCount}"); + } + + public Task AccessDenied(string permission, string permissionAction) + { + var details = string.IsNullOrEmpty(permissionAction) + ? $"permission: {permission}" + : $"permission: {permission} / {permissionAction}"; + return Record(AuditKind.AccessDenied, CurrentStaff(), entry => entry.Details = details); + } + + public Task Plugin(string pluginName, string change) + { + return Record(AuditKind.Plugin, CurrentStaff(), entry => + { + entry.EntityType = "Plugin"; + entry.EntityName = pluginName; + entry.Details = change; + }); + } + + private async Task RecordSignInEvent(AuditKind kind, Customer customer, string reason) + { + try + { + if (!auditConfig.LogStaffSignIns || customer == null || !await IsStaff(customer)) + return; + } + catch (Exception ex) + { + logger.LogError(ex, "Audit {Kind} of customer {CustomerId} was not recorded", kind, customer?.Id); + return; + } + + await Record(kind, customer, entry => + { + entry.EntityType = nameof(Customer); + entry.EntityId = customer.Id; + entry.EntityName = customer.Email; + entry.Details = reason; + }); + } + + private async Task IsStaff(Customer customer) + { + return await groupService.IsAdmin(customer) + || await groupService.IsStoreManager(customer) + || await groupService.IsVendor(customer) + || await groupService.IsSalesManager(customer); + } + + //the staff member behind the request, also while impersonating a customer + private Customer CurrentStaff() + { + var workContext = contextAccessor.WorkContext; + return workContext?.OriginalCustomerIfImpersonated ?? workContext?.CurrentCustomer; + } + + private static void Target(AuditEntry entry, Customer target) + { + entry.EntityType = nameof(Customer); + entry.EntityId = target?.Id; + entry.EntityName = target?.Email; + entry.ImpersonatedCustomerId = target?.Id; + } + + private async Task Record(AuditKind kind, Customer actor, Action fill) + { + try + { + //an event outside the panels (a storefront sign-in) is a storefront event + var httpContext = httpContextAccessor.HttpContext; + var routeValues = httpContext?.Request.RouteValues; + var source = AuditRequest.StaffSource(httpContext?.GetEndpoint()?.Metadata, routeValues) + ?? AuditSource.Storefront; + var entry = new AuditEntry { Kind = kind }; + AuditRequest.Actor(httpContext, routeValues, source, actor?.Id, actor?.Email, null, actor?.StaffStoreId, + actor?.VendorId).Stamp(entry); + fill?.Invoke(entry); + await auditService.InsertEvent(entry); + } + catch (Exception ex) + { + logger.LogError(ex, "Audit {Kind} of customer {CustomerId} was not recorded", kind, actor?.Id); + } + } +} diff --git a/src/Web/Grand.Web.Common/Audit/AuditRequest.cs b/src/Web/Grand.Web.Common/Audit/AuditRequest.cs new file mode 100644 index 0000000000..aa8310c03f --- /dev/null +++ b/src/Web/Grand.Web.Common/Audit/AuditRequest.cs @@ -0,0 +1,48 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using System.Diagnostics; + +namespace Grand.Web.Common.Audit; + +/// +/// Describes a request the same way for the data-change scope and for the recorded events, so the entries of +/// one operation agree on source and action +/// +public static class AuditRequest +{ + /// + /// The source of a panel, API or SCIM request: an [AuditSource] mark wins over the area. Null otherwise. + /// + public static AuditSource? StaffSource(IEnumerable endpointMetadata, RouteValueDictionary routeValues) + { + var marked = endpointMetadata?.OfType().LastOrDefault(); + if (marked != null) + return marked.Source; + + return routeValues?["area"] as string switch { + "Admin" => AuditSource.Admin, + "Store" => AuditSource.Store, + "Vendor" => AuditSource.Vendor, + _ => null + }; + } + + public static AuditActor Actor(HttpContext httpContext, RouteValueDictionary routeValues, AuditSource source, + string customerId, string email, string impersonatedCustomerId, string storeId, string vendorId) + { + return new AuditActor( + source, + customerId, + email, + impersonatedCustomerId, + storeId, + vendorId, + httpContext?.Connection.RemoteIpAddress?.ToString(), + httpContext?.Request.Headers.UserAgent.ToString(), + httpContext == null ? null : $"{source} / {routeValues["controller"]} / {routeValues["action"]}", + httpContext == null ? null : Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier); + } +} diff --git a/src/Web/Grand.Web.Common/Audit/AuditSignInEventHandler.cs b/src/Web/Grand.Web.Common/Audit/AuditSignInEventHandler.cs new file mode 100644 index 0000000000..cf5c9dee49 --- /dev/null +++ b/src/Web/Grand.Web.Common/Audit/AuditSignInEventHandler.cs @@ -0,0 +1,29 @@ +using Grand.Business.Core.Events.Customers; +using Grand.Mediator; + +namespace Grand.Web.Common.Audit; + +/// +/// Records staff sign-ins, failed sign-ins and sign-outs from the events the login flows already publish +/// (storefront, panels and external sign-in) +/// +public class AuditSignInEventHandler(IAuditEventRecorder recorder) : + INotificationHandler, + INotificationHandler, + INotificationHandler +{ + public Task Handle(CustomerLoggedInEvent notification, CancellationToken cancellationToken) + { + return recorder.SignIn(notification.Customer); + } + + public Task Handle(CustomerLoginFailedEvent notification, CancellationToken cancellationToken) + { + return recorder.SignInFailed(notification.Customer, notification.Reason); + } + + public Task Handle(CustomerLoggedOutEvent notification, CancellationToken cancellationToken) + { + return recorder.SignOut(notification.Customer); + } +} diff --git a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs new file mode 100644 index 0000000000..551471153c --- /dev/null +++ b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs @@ -0,0 +1,43 @@ +using Grand.Domain.Customers; + +namespace Grand.Web.Common.Audit; + +/// +/// Records the audit events that are not data changes. Never throws: a failure is logged. +/// +public interface IAuditEventRecorder +{ + Task SignIn(Customer customer); + + Task SignInFailed(Customer customer, string reason); + + Task SignOut(Customer customer); + + /// + /// Records the sign-out of the staff member behind the current request + /// + Task SignOutCurrent(); + + /// + /// Records that the current staff member exported entity data as a file + /// + Task Export(string entityType, string filterSummary, int? rowCount); + + Task ImpersonationStart(Customer target); + + Task ImpersonationEnd(Customer target); + + Task AuditLogViewed(); + + Task AuditLogExported(string filterSummary, int rowCount); + + /// + /// Records that the current staff member was refused an admin page or action for lack of a permission + /// + Task AccessDenied(string permission, string permissionAction); + + /// + /// Records a plugin installed, uninstalled, removed or uploaded by the current staff member + /// + Task Plugin(string pluginName, string change); +} diff --git a/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs b/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs new file mode 100644 index 0000000000..7a921e6674 --- /dev/null +++ b/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs @@ -0,0 +1,88 @@ +using Grand.Infrastructure.Audit; +using Grand.Web.Common.Audit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System.Collections; + +namespace Grand.Web.Common.Filters; + +/// +/// Records an audit Export event when the action hands out a file, with a summary of the filter it was given +/// +public class AuditExportAttribute : TypeFilterAttribute +{ + public AuditExportAttribute(string entityType) : base(typeof(AuditExportFilter)) + { + Arguments = [entityType]; + } + + public class AuditExportFilter(string entityType, IAuditEventRecorder recorder) : IAsyncActionFilter + { + private const int MaxSummaryLength = 500; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + //the summary is only for the log, so a model it cannot read must not fail the export + string summary; + try + { + summary = Summarize(context.ActionArguments); + } + catch (Exception) + { + summary = "unreadable"; + } + + var executed = await next(); + if (executed.Exception == null && executed.Result is FileResult) + await recorder.Export(entityType, summary, null); + } + + private static string Summarize(IDictionary arguments) + { + var parts = new List(); + foreach (var (name, value) in arguments) + { + if (value == null || value is IFormFile or CancellationToken) + continue; + if (IsSimple(value.GetType())) + Add(parts, name, value); + else if (value is not IEnumerable) + foreach (var property in value.GetType().GetProperties() + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)) + Add(parts, property.Name, property.GetValue(value)); + } + + var summary = string.Join("; ", parts); + return summary.Length > MaxSummaryLength ? summary[..MaxSummaryLength] : summary; + } + + private static void Add(List parts, string name, object value) + { + if (value == null || AuditDiff.IsSensitive(name)) + return; + + var type = value.GetType(); + if (IsSimple(type)) + { + if (value is string text && string.IsNullOrEmpty(text)) + return; + if (type.IsValueType && !type.IsEnum && Equals(value, Activator.CreateInstance(type))) + return; + parts.Add($"{name}={value}"); + } + else if (value is ICollection { Count: > 0 } collection) + { + parts.Add($"{name}: {collection.Count} items"); + } + } + + private static bool IsSimple(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) + || type == typeof(DateTime) || type == typeof(Guid); + } + } +} diff --git a/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs b/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs new file mode 100644 index 0000000000..b12b28d486 --- /dev/null +++ b/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs @@ -0,0 +1,71 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using Grand.Infrastructure; +using Grand.Web.Common.Audit; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Grand.Web.Common.Filters; + +/// +/// Activates the audit tracker for staff requests (the Admin, Store and Vendor panels, the API and SCIM, and the +/// storefront while a staff member impersonates a customer), and flushes it when the request ends. +/// The repositories record what changed; this filter decides whether to record and who acted. +/// +public class AuditScopeFilter(IAuditTracker tracker, IContextAccessor contextAccessor) : IAsyncResourceFilter +{ + public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next) + { + var actor = ResolveActor(context); + if (actor == null) + { + await next(); + return; + } + + tracker.Activate(actor); + try + { + await next(); + } + finally + { + await tracker.CompleteAsync(); + } + } + + private AuditActor ResolveActor(ResourceExecutingContext context) + { + var httpContext = context.HttpContext; + var workContext = contextAccessor.WorkContext; + var original = workContext?.OriginalCustomerIfImpersonated; + var source = ResolveSource(context, original != null); + if (source == null) + return null; + + //SCIM checks its bearer token in its own filter, not through an authentication scheme + if (source != AuditSource.Scim && httpContext.User.Identity?.IsAuthenticated != true) + return null; + + var current = workContext?.CurrentCustomer; + var staff = original ?? current; + var customerId = staff?.Id; + var email = staff?.Email; + if (source == AuditSource.Scim && string.IsNullOrEmpty(email)) + { + customerId = null; + email = "SCIM"; + } + + return AuditRequest.Actor(httpContext, context.RouteData.Values, source.Value, customerId, email, + original != null ? current?.Id : null, staff?.StaffStoreId, staff?.VendorId); + } + + private static AuditSource? ResolveSource(ResourceExecutingContext context, bool impersonating) + { + var routeValues = context.RouteData.Values; + return AuditRequest.StaffSource(context.ActionDescriptor.EndpointMetadata, routeValues) + ?? (impersonating && string.IsNullOrEmpty(routeValues["area"] as string) + ? AuditSource.Storefront + : null); + } +} diff --git a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs index f1757c054a..bcfbf6d595 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Plugins; using Grand.Infrastructure.TypeSearch; +using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Oidc; using Grand.Web.Common.View; using Microsoft.AspNetCore.Builder; @@ -213,7 +214,11 @@ public static IMvcBuilder AddGrandMvc(this IServiceCollection services, IConfigu }); mvcBuilder.AddMvcOptions(options => - options.Conventions.Add(new SharedViewFolderControllerNameConvention())); + { + options.Conventions.Add(new SharedViewFolderControllerNameConvention()); + //records staff writes of panel, API and SCIM requests in the audit trail + options.Filters.Add(); + }); //add view localization mvcBuilder.AddViewLocalization(); diff --git a/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeActionAttribute.cs b/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeActionAttribute.cs index 67d981df8c..a247419c20 100644 --- a/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeActionAttribute.cs +++ b/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeActionAttribute.cs @@ -1,6 +1,7 @@ using Grand.Business.Core.Interfaces.Common.Security; using Grand.Domain.Permissions; using Grand.Infrastructure; +using Grand.Web.Common.Audit; using Grand.Web.Common.DataSource; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -51,6 +52,8 @@ await permissionService.AuthorizeAction(permissionAuthorize.Permission, Permissi } else { + await context.HttpContext.RequestServices.GetRequiredService() + .AccessDenied(permissionAuthorize.Permission, PermissionAction); var isAjaxCall = context.HttpContext.Request.Headers["x-requested-with"] == "XMLHttpRequest"; if (!isAjaxCall) context.Result = new RedirectToActionResult("AccessDenied", "Home", diff --git a/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeAttribute.cs b/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeAttribute.cs index 9e39334865..3c0251446d 100644 --- a/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeAttribute.cs +++ b/src/Web/Grand.Web.Common/Security/Authorization/PermissionAuthorizeAttribute.cs @@ -1,4 +1,5 @@ using Grand.Domain.Permissions; +using Grand.Web.Common.Audit; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; @@ -32,9 +33,14 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext context) //authorize permission of access to the admin area if (!await permissionService.Authorize(StandardPermission.ManageAccessAdminPanel)) + { context.Result = new RedirectToRouteResult("AdminLogin", new RouteValueDictionary()); - else - context.Result = new RedirectToActionResult("AccessDenied", "Home", - new { pageUrl = context.HttpContext.Request.Path }); + return; + } + + await context.HttpContext.RequestServices.GetRequiredService() + .AccessDenied(Permission, null); + context.Result = new RedirectToActionResult("AccessDenied", "Home", + new { pageUrl = context.HttpContext.Request.Path }); } } \ No newline at end of file diff --git a/src/Web/Grand.Web.Common/Startup/StartupApplication.cs b/src/Web/Grand.Web.Common/Startup/StartupApplication.cs index df3b46d03a..1a54452dd1 100644 --- a/src/Web/Grand.Web.Common/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Common/Startup/StartupApplication.cs @@ -7,6 +7,7 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Captcha; +using Grand.Web.Common.Audit; using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; using Grand.Web.Common.Menu; @@ -129,6 +130,7 @@ private static void RegisterFramework(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/src/Web/Grand.Web.Common/TagHelpers/Admin/AuditHistoryTagHelper.cs b/src/Web/Grand.Web.Common/TagHelpers/Admin/AuditHistoryTagHelper.cs new file mode 100644 index 0000000000..bad0cc7767 --- /dev/null +++ b/src/Web/Grand.Web.Common/TagHelpers/Admin/AuditHistoryTagHelper.cs @@ -0,0 +1,63 @@ +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using System.Text.Encodings.Web; + +namespace Grand.Web.Common.TagHelpers.Admin; + +/// +/// The "Change history" button that opens the audit history of one record (entity-id set) or of one entity type. +/// Renders nothing outside Main Admin or without the audit log permission, so a shared view stays safe in +/// the Store and Vendor panels. +/// +[HtmlTargetElement("audit-history", TagStructure = TagStructure.WithoutEndTag)] +public class AuditHistoryTagHelper( + IRequestPermissionService permissionService, + ITranslationService translationService, + IContextAccessor contextAccessor) : TagHelper +{ + private const string AdminArea = "Admin"; + + [ViewContext] [HtmlAttributeNotBound] public ViewContext ViewContext { get; set; } + + [HtmlAttributeName("entity-type")] public string EntityType { get; set; } + + [HtmlAttributeName("entity-id")] public string EntityId { get; set; } + + /// + /// Comma-separated entity names to limit a per-type history to, e.g. the setting classes of a settings page + /// + [HtmlAttributeName("entity-names")] public string EntityNames { get; set; } + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + output.TagName = null; + if (ViewContext.RouteData.Values["area"] as string != AdminArea || string.IsNullOrEmpty(EntityType) + || !await permissionService.Authorize(PermissionSystemName.AuditLog)) + { + output.SuppressOutput(); + return; + } + + var url = $"{ViewContext.HttpContext.Request.PathBase}/{AdminArea}/AuditLog/HistoryPopup" + + $"?entityType={Uri.EscapeDataString(EntityType)}" + + (string.IsNullOrEmpty(EntityId) ? "" : $"&entityId={Uri.EscapeDataString(EntityId)}") + + (string.IsNullOrEmpty(EntityNames) ? "" : $"&entityNames={Uri.EscapeDataString(EntityNames)}"); + var id = "audit-history-" + Guid.NewGuid().ToString("N")[..8]; + var text = AdminText.Resource(translationService, contextAccessor, "Admin.System.AuditLog.ChangeHistory", + "Change history"); + var encoder = HtmlEncoder.Default; + + output.Content.SetHtmlContent( + $"" + + $" {encoder.Encode(text)}" + + ""); + } +} diff --git a/src/Web/Grand.Web.Store/App_Data/appsettings.json b/src/Web/Grand.Web.Store/App_Data/appsettings.json index 511f5017e2..0bcd3f9b71 100644 --- a/src/Web/Grand.Web.Store/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Store/App_Data/appsettings.json @@ -211,6 +211,15 @@ "ClientId": "", "ClientSecret": "" }, + "Audit": { + //Staff audit trail. Set here, not in the admin panel, so staff cannot shorten or switch off what it keeps. + //Keep the values identical on every host (Grand.Web, Grand.Web.Admin, Grand.Web.Store, Grand.Web.Vendor). + //days an entry is kept before the daily retention task deletes it; 0 keeps entries forever + //(SOC 2 / ISO 27001 audits usually expect at least 365) + "RetentionDays": 365, + //records staff sign-ins, failed sign-ins and sign-outs + "LogStaffSignIns": true + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Web/Grand.Web.Vendor/App_Data/appsettings.json b/src/Web/Grand.Web.Vendor/App_Data/appsettings.json index d80ccdc7d3..1d4ce0a7ed 100644 --- a/src/Web/Grand.Web.Vendor/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Vendor/App_Data/appsettings.json @@ -214,6 +214,15 @@ "ClientId": "", "ClientSecret": "" }, + "Audit": { + //Staff audit trail. Set here, not in the admin panel, so staff cannot shorten or switch off what it keeps. + //Keep the values identical on every host (Grand.Web, Grand.Web.Admin, Grand.Web.Store, Grand.Web.Vendor). + //days an entry is kept before the daily retention task deletes it; 0 keeps entries forever + //(SOC 2 / ISO 27001 audits usually expect at least 365) + "RetentionDays": 365, + //records staff sign-ins, failed sign-ins and sign-outs + "LogStaffSignIns": true + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index b513afc5ac..2c6155bd59 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index cf118788a9..cf98ef9c9a 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -474,4 +474,160 @@ The request has been withdrawn. + + Audit log + + + Change history + + + Open in audit log + + + Export CSV + + + Audit entry + + + All entries of this operation + + + changed (hidden) + + + truncated + + + No field changes + + + No changes recorded yet + + + All stores + + + Applies to + + + When (UTC) + + + Who + + + Panel + + + Action + + + Entity type + + + Entity id + + + Entity + + + Changed fields + + + IP address + + + User agent + + + Request + + + Operation + + + Store + + + Details + + + Impersonated customer + + + Field + + + Before + + + After + + + From (UTC) + + + To (UTC) + + + Insert + + + Update + + + Delete + + + Bulk change + + + Sign-in + + + Sign-in failed + + + Sign-out + + + Export + + + Impersonation started + + + Impersonation ended + + + Audit log viewed + + + Audit log exported + + + Access denied + + + Plugin change + + + Admin + + + Store + + + Vendor + + + API + + + SCIM + + + Storefront + diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index f05cb04709..8cc0c015e1 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -333,6 +333,15 @@ //largest page of a list response "MaxResults": 100 }, + "Audit": { + //Staff audit trail. Set here, not in the admin panel, so staff cannot shorten or switch off what it keeps. + //Keep the values identical on every host (Grand.Web, Grand.Web.Admin, Grand.Web.Store, Grand.Web.Vendor). + //days an entry is kept before the daily retention task deletes it; 0 keeps entries forever + //(SOC 2 / ISO 27001 audits usually expect at least 365) + "RetentionDays": 365, + //records staff sign-ins, failed sign-ins and sign-outs + "LogStaffSignIns": true + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 8c2755ebb0..b03f497093 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -14,6 +14,7 @@ using Grand.Infrastructure.Extensions; using Grand.SharedKernel.Attributes; using Grand.Web.Commands.Models.Customers; +using Grand.Web.Common.Audit; using Grand.Web.Common.Controllers; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authentication; @@ -349,7 +350,8 @@ public async Task TwoFactorAuthorization(string token, _translationService.GetResource("Account.TwoFactorAuth.WrongSecurityCode")); } - await _mediator.Publish(new CustomerLoginFailedEvent(customer)); + await _mediator.Publish(new CustomerLoginFailedEvent(customer, + string.IsNullOrEmpty(token) ? "two-factor code missing" : "wrong two-factor code")); return View(); } @@ -379,13 +381,15 @@ await _mediator.Publish(new CustomerLoggedInEvent(customer, [IgnoreApi] public virtual async Task Logout( [FromServices] StoreInformationSettings storeInformationSettings, - [FromServices] IProviderSignOut providerSignOut) + [FromServices] IProviderSignOut providerSignOut, + [FromServices] IAuditEventRecorder auditEventRecorder) { if (_contextAccessor.WorkContext.OriginalCustomerIfImpersonated != null) { //logout impersonated customer await _customerService.UpdateUserField(_contextAccessor.WorkContext.OriginalCustomerIfImpersonated, SystemCustomerFieldNames.ImpersonatedCustomerId, null); + await auditEventRecorder.ImpersonationEnd(_contextAccessor.WorkContext.CurrentCustomer); //redirect back to customer details page (admin area) return RedirectToAction("Edit", "Customer", diff --git a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs index 98ab883730..870be286e1 100644 --- a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs @@ -83,7 +83,7 @@ public LoginValidator( { context.AddFailure(translationService.GetResource("Account.Login.WrongCredentials")); await contextAccessor.HttpContext!.RequestServices.GetRequiredService() - .Publish(new CustomerLoginFailedEvent(customer), _); + .Publish(new CustomerLoginFailedEvent(customer, "wrong credentials"), _); } else if (await externalSignInPolicy.PasswordSignIn(customer, SignInSurface.Storefront) is { Allowed: false } decision) {