From cfde359e13ab71f261beddc7431ecc1a4ee7200c Mon Sep 17 00:00:00 2001 From: support Date: Wed, 28 Oct 2026 15:52:25 +0100 Subject: [PATCH 1/7] Record staff changes in an admin audit trail Every write made by staff - in the Admin, Store and Vendor panels, through the API and SCIM, and on the storefront while impersonating a customer - leaves an entry with the actor, time, source, IP, user agent, request action, operation id and the changed fields before and after. Staff sign-ins, failed sign-ins, sign-outs, exports, impersonation and access to the log itself are recorded too. Entries cannot be edited or deleted; a daily retention task (365 days by default) is the only way out. Capture - Auditing subclasses of the Mongo and LiteDB repositories report every write to a scoped tracker, so existing `is MongoRepository` checks keep working and IRepository is unchanged. - The tracker is inactive until a global MVC filter activates it for a staff request: storefront traffic and scheduled tasks pay one boolean check. - Each entity is read once before its first write; "after" is read in batches of 200 and at request end, and entries are written with one batch insert. - The diff is generic on BSON and limited to the fields the request wrote, so a customer buying a product while an admin renames it does not show up as the admin's stock change. Arrays are matched by element id or key, settings JSON is diffed field by field, and passwords, salts, tokens, API keys and secrets - also inside user fields and settings - are masked. - A failing audit read, diff or write is logged and never fails the request. Admin - System > Audit log in Main Admin only: filters, entry detail, CSV export (formula-safe), retention and sign-in settings, behind the new ManageAuditLog permission. - A "Change history" button on edit and list screens opens the history of one record or of one entity type; on settings pages it is limited to the setting classes the page saves, and entries show which store a setting applies to. - An AuditEntryCreated notification lets a plugin forward entries elsewhere. Install and upgrade: indexes, the retention task, the permission (installer list and PermissionProvider), the menu entry and resources, plus a 2.5 migration for existing installations. --- .../Services/Audit/AuditService.cs | 119 +++++ .../Services/Security/PermissionProvider.cs | 2 + .../Startup/StartupApplication.cs | 3 + .../Common/Audit/AuditEntrySearch.cs | 24 + .../Interfaces/Common/Audit/IAuditService.cs | 38 ++ src/Core/Grand.Data/Audit/AuditActor.cs | 18 + src/Core/Grand.Data/Audit/AuditFilterId.cs | 106 +++++ src/Core/Grand.Data/Audit/AuditIndexes.cs | 25 + .../Grand.Data/Audit/AuditRepositoryAccess.cs | 12 + .../Grand.Data/Audit/IAuditEntryWriter.cs | 11 + src/Core/Grand.Data/Audit/IAuditTracker.cs | 32 ++ src/Core/Grand.Data/Audit/RepositoryAudit.cs | 76 +++ .../LiteDb/AuditingLiteDBRepository.cs | 113 +++++ .../LiteDb/LiteDBAuditEntryWriter.cs | 17 + .../Grand.Data/LiteDb/LiteDBRepository.cs | 2 +- .../Mongo/AuditingMongoRepository.cs | 112 +++++ .../Grand.Data/Mongo/MongoAuditEntryWriter.cs | 16 + src/Core/Grand.Data/Mongo/MongoRepository.cs | 2 +- src/Core/Grand.Domain/Audit/AuditChange.cs | 32 ++ src/Core/Grand.Domain/Audit/AuditEntry.cs | 72 +++ src/Core/Grand.Domain/Audit/AuditKind.cs | 20 + src/Core/Grand.Domain/Audit/AuditSettings.cs | 16 + src/Core/Grand.Domain/Audit/AuditSource.cs | 14 + .../Permissions/PermissionSystemName.cs | 1 + .../Permissions/StandardPermissionSystem.cs | 9 + .../Grand.Infrastructure/Audit/AuditDiff.cs | 299 ++++++++++++ .../Audit/AuditEntryCreated.cs | 12 + .../Audit/AuditSourceAttribute.cs | 12 + .../Audit/AuditTracker.cs | 306 ++++++++++++ .../Startup/StartupApplication.cs | 16 +- .../Controllers/BaseApiController.cs | 3 + .../Extensions/PermissionExtensions.cs | 2 + .../Services/InstallDataScheduleTasks.cs | 6 + .../Services/InstallDataSettings.cs | 2 + .../Services/InstallationService.cs | 8 + .../Utilities/PermissionSystemName.cs | 1 + .../Utilities/StandardAdminSiteMap.cs | 12 +- .../Migrations/2.5/MigrationAuditLog.cs | 121 +++++ .../AuditLogRetentionScheduleTask.cs | 18 + .../Startup/StartupApplication.cs | 1 + .../Controllers/ScimControllerBase.cs | 3 + .../Services/Audit/AuditServiceTests.cs | 154 ++++++ .../Security/PermissionProviderTests.cs | 21 + .../Audit/AuditDiffTests.cs | 327 +++++++++++++ .../Audit/AuditFilterIdTests.cs | 82 ++++ .../Audit/AuditTrackerTests.cs | 447 ++++++++++++++++++ .../Audit/AuditingLiteDBRepositoryTests.cs | 202 ++++++++ .../Installer/PermissionSeedTests.cs | 30 ++ .../AuditLogRetentionScheduleTaskTests.cs | 55 +++ .../Controllers/AuditLogControllerTests.cs | 301 ++++++++++++ .../BaseCustomerManagementControllerTests.cs | 18 +- .../Controllers/BaseHomeControllerTests.cs | 16 +- .../Audit/AuditEventRecorderTests.cs | 216 +++++++++ .../Filters/AuditExportAttributeTests.cs | 74 +++ .../Filters/AuditScopeFilterTests.cs | 204 ++++++++ .../TagHelpers/AuditHistoryTagHelperTests.cs | 99 ++++ .../Areas/Admin/Views/Affiliate/Edit.cshtml | 1 + .../Areas/Admin/Views/ApiUser/Index.cshtml | 1 + .../Areas/Admin/Views/AuditLog/Detail.cshtml | 109 +++++ .../Admin/Views/AuditLog/HistoryPopup.cshtml | 51 ++ .../Areas/Admin/Views/AuditLog/List.cshtml | 95 ++++ .../Areas/Admin/Views/Campaign/Edit.cshtml | 1 + .../Areas/Admin/Views/Country/Edit.cshtml | 1 + .../Areas/Admin/Views/Course/Edit.cshtml | 1 + .../Admin/Views/Course/EditLesson.cshtml | 1 + .../Areas/Admin/Views/Course/Level.cshtml | 3 + .../Areas/Admin/Views/Currency/Edit.cshtml | 1 + .../Areas/Admin/Views/Customer/Edit.cshtml | 1 + .../Admin/Views/CustomerGroup/Edit.cshtml | 1 + .../Areas/Admin/Views/CustomerTag/Edit.cshtml | 1 + .../Admin/Views/Document/EditDocument.cshtml | 1 + .../Admin/Views/Document/EditType.cshtml | 1 + .../Views/Knowledgebase/EditArticle.cshtml | 1 + .../Views/Knowledgebase/EditCategory.cshtml | 1 + .../Areas/Admin/Views/Language/Edit.cshtml | 1 + .../Admin/Views/Layout/BrandLayouts.cshtml | 1 + .../Admin/Views/Layout/CategoryLayouts.cshtml | 1 + .../Views/Layout/CollectionLayouts.cshtml | 1 + .../Admin/Views/Layout/PageLayouts.cshtml | 1 + .../Admin/Views/Layout/ProductLayouts.cshtml | 1 + .../Areas/Admin/Views/Menu/Edit.cshtml | 1 + .../Views/NewsLetterSubscription/List.cshtml | 1 + .../Views/NewsletterCategory/Edit.cshtml | 1 + .../Admin/Views/OrderStatus/Index.cshtml | 3 + .../Areas/Admin/Views/OrderTags/Edit.cshtml | 1 + .../Areas/Admin/Views/ProductTags/Edit.cshtml | 1 + .../Admin/Views/SalesEmployee/Index.cshtml | 3 + .../Views/ScheduleTask/EditScheduler.cshtml | 1 + .../Admin/Views/Setting/AdminSearch.cshtml | 1 + .../Areas/Admin/Views/Setting/Catalog.cshtml | 1 + .../Areas/Admin/Views/Setting/Content.cshtml | 1 + .../Areas/Admin/Views/Setting/Customer.cshtml | 1 + .../Admin/Views/Setting/GeneralCommon.cshtml | 1 + .../Areas/Admin/Views/Setting/Media.cshtml | 1 + .../MerchandiseReturnActionEdit.cshtml | 1 + .../MerchandiseReturnReasonEdit.cshtml | 1 + .../Views/Setting/PushNotifications.cshtml | 1 + .../Areas/Admin/Views/Setting/Sales.cshtml | 1 + .../Admin/Views/Setting/SystemSetting.cshtml | 1 + .../Areas/Admin/Views/Setting/Vendor.cshtml | 1 + .../Views/Shipping/EditDeliveryDate.cshtml | 1 + .../Admin/Views/Shipping/EditMethod.cshtml | 1 + .../Views/Shipping/EditPickupPoint.cshtml | 1 + .../Admin/Views/Shipping/EditWarehouse.cshtml | 1 + .../Areas/Admin/Views/Store/Edit.cshtml | 1 + .../Areas/Admin/Views/Tax/Categories.cshtml | 1 + .../Areas/Admin/Views/Vendor/Edit.cshtml | 1 + .../Controllers/AuditLogController.cs | 327 +++++++++++++ .../Controllers/CampaignController.cs | 1 + .../Controllers/CountryController.cs | 1 + .../Controllers/LanguageController.cs | 1 + .../NewsLetterSubscriptionController.cs | 2 + .../Controllers/OrderController.cs | 2 + .../Models/AuditLog/AuditEntryModel.cs | 61 +++ .../Models/AuditLog/AuditLogListModel.cs | 46 ++ .../Models/AuditLog/AuditSettingsModel.cs | 13 + .../AuditLog/AuditSettingsValidator.cs | 17 + .../Controllers/BaseBrandController.cs | 1 + .../Controllers/BaseCategoryController.cs | 1 + .../Controllers/BaseCollectionController.cs | 1 + .../BaseCustomerManagementController.cs | 7 +- .../Controllers/BaseHomeController.cs | 5 +- .../Controllers/BaseOrderController.cs | 4 + .../Controllers/BaseProductController.cs | 2 + .../Controllers/BaseShipmentController.cs | 3 + .../AdminShared/AddressAttribute/Edit.cshtml | 1 + .../Views/AdminShared/Blog/Edit.cshtml | 1 + .../Views/AdminShared/Brand/Edit.cshtml | 1 + .../Views/AdminShared/Category/Edit.cshtml | 1 + .../AdminShared/CheckoutAttribute/Edit.cshtml | 1 + .../Views/AdminShared/Collection/Edit.cshtml | 1 + .../AdminShared/ContactAttribute/Edit.cshtml | 1 + .../AdminShared/CustomerAttribute/Edit.cshtml | 1 + .../Views/AdminShared/Discount/Edit.cshtml | 1 + .../AdminShared/EmailAccount/Edit.cshtml | 1 + .../Views/AdminShared/GiftVoucher/Edit.cshtml | 1 + .../AdminShared/MerchandiseReturn/Edit.cshtml | 1 + .../AdminShared/MessageTemplate/Edit.cshtml | 1 + .../Views/AdminShared/News/Edit.cshtml | 1 + .../Views/AdminShared/Order/Edit.cshtml | 1 + .../Views/AdminShared/Page/Edit.cshtml | 1 + .../PaymentTransaction/Edit.cshtml | 1 + .../Views/AdminShared/Product/Edit.cshtml | 1 + .../AdminShared/ProductAttribute/Edit.cshtml | 1 + .../AdminShared/ProductReview/Edit.cshtml | 1 + .../SpecificationAttribute/Edit.cshtml | 1 + .../AdminShared/VendorReview/Edit.cshtml | 1 + .../Audit/AuditEventRecorder.cs | 157 ++++++ .../Audit/AuditSignInEventHandler.cs | 29 ++ .../Audit/IAuditEventRecorder.cs | 33 ++ .../Filters/AuditExportAttribute.cs | 80 ++++ .../Filters/AuditScopeFilter.cs | 92 ++++ .../ServiceCollectionExtensions.cs | 7 +- .../Startup/StartupApplication.cs | 2 + .../TagHelpers/Admin/AuditHistoryTagHelper.cs | 63 +++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1617232 -> 1630746 bytes .../App_Data/Resources/Upgrade/en_250.xml | 168 +++++++ .../Controllers/AccountController.cs | 5 +- 158 files changed, 5407 insertions(+), 15 deletions(-) create mode 100644 src/Business/Grand.Business.Common/Services/Audit/AuditService.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Common/Audit/IAuditService.cs create mode 100644 src/Core/Grand.Data/Audit/AuditActor.cs create mode 100644 src/Core/Grand.Data/Audit/AuditFilterId.cs create mode 100644 src/Core/Grand.Data/Audit/AuditIndexes.cs create mode 100644 src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs create mode 100644 src/Core/Grand.Data/Audit/IAuditEntryWriter.cs create mode 100644 src/Core/Grand.Data/Audit/IAuditTracker.cs create mode 100644 src/Core/Grand.Data/Audit/RepositoryAudit.cs create mode 100644 src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs create mode 100644 src/Core/Grand.Data/LiteDb/LiteDBAuditEntryWriter.cs create mode 100644 src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs create mode 100644 src/Core/Grand.Data/Mongo/MongoAuditEntryWriter.cs create mode 100644 src/Core/Grand.Domain/Audit/AuditChange.cs create mode 100644 src/Core/Grand.Domain/Audit/AuditEntry.cs create mode 100644 src/Core/Grand.Domain/Audit/AuditKind.cs create mode 100644 src/Core/Grand.Domain/Audit/AuditSettings.cs create mode 100644 src/Core/Grand.Domain/Audit/AuditSource.cs create mode 100644 src/Core/Grand.Infrastructure/Audit/AuditDiff.cs create mode 100644 src/Core/Grand.Infrastructure/Audit/AuditEntryCreated.cs create mode 100644 src/Core/Grand.Infrastructure/Audit/AuditSourceAttribute.cs create mode 100644 src/Core/Grand.Infrastructure/Audit/AuditTracker.cs create mode 100644 src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationAuditLog.cs create mode 100644 src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs create mode 100644 src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs create mode 100644 src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs create mode 100644 src/Tests/Grand.Infrastructure.Tests/Audit/AuditFilterIdTests.cs create mode 100644 src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs create mode 100644 src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Installer/PermissionSeedTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/TagHelpers/AuditHistoryTagHelperTests.cs create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/HistoryPopup.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml create mode 100644 src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs create mode 100644 src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs create mode 100644 src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs create mode 100644 src/Web/Grand.Web.Admin/Models/AuditLog/AuditSettingsModel.cs create mode 100644 src/Web/Grand.Web.Admin/Validators/AuditLog/AuditSettingsValidator.cs create mode 100644 src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs create mode 100644 src/Web/Grand.Web.Common/Audit/AuditSignInEventHandler.cs create mode 100644 src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs create mode 100644 src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs create mode 100644 src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs create mode 100644 src/Web/Grand.Web.Common/TagHelpers/Admin/AuditHistoryTagHelper.cs 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..9234929052 --- /dev/null +++ b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs @@ -0,0 +1,119 @@ +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.Mediator; +using System.Runtime.CompilerServices; + +namespace Grand.Business.Common.Services.Audit; + +public class AuditService( + IRepository repository, + IAuditEntryWriter writer, + IMediator mediator) : IAuditService +{ + private const int StreamPageSize = 1000; + + 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 + }; + + for (var pageIndex = 0;; pageIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await repository.PagedAsync(Query(bounded), pageIndex, StreamPageSize, cancellationToken); + foreach (var entry in page) + yield return entry; + + if (page.Count < StreamPageSize) + yield break; + } + } + + public async Task> GetEntityTypes() + { + var types = await repository.ToListAsync(repository.Table.Select(x => x.EntityType).Distinct()); + return types.Where(t => !string.IsNullOrEmpty(t)).OrderBy(t => t, StringComparer.Ordinal).ToList(); + } + + 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); + 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/Interfaces/Common/Audit/AuditEntrySearch.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs new file mode 100644 index 0000000000..b283650a87 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs @@ -0,0 +1,24 @@ +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; } + 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..105bf029e9 --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditActor.cs @@ -0,0 +1,18 @@ +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); diff --git a/src/Core/Grand.Data/Audit/AuditFilterId.cs b/src/Core/Grand.Data/Audit/AuditFilterId.cs new file mode 100644 index 0000000000..e433f071d8 --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditFilterId.cs @@ -0,0 +1,106 @@ +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; + value = member.Member switch { + FieldInfo field => field.GetValue(target), + PropertyInfo property => property.GetValue(target), + _ => null + }; + 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/AuditRepositoryAccess.cs b/src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs new file mode 100644 index 0000000000..945b0a9228 --- /dev/null +++ b/src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs @@ -0,0 +1,12 @@ +using Grand.Domain; + +namespace Grand.Data.Audit; + +/// +/// How the tracker reads documents of one type back, supplied by the repository +/// +public sealed class AuditRepositoryAccess(Func, Task>> loadMany) + where T : BaseEntity +{ + public Func, Task>> LoadMany { get; } = loadMany; +} 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..f9487c2a0c --- /dev/null +++ b/src/Core/Grand.Data/Audit/IAuditTracker.cs @@ -0,0 +1,32 @@ +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; null means every field. + /// + Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryAccess access, + IReadOnlyCollection writtenPaths = null) where T : BaseEntity; + + /// + /// Keeps the entity of a full update as its "after" state, so the flush does not re-read it + /// + void AfterFullUpdate(T entity) where T : BaseEntity; + + void RecordBulk(string operation, string filter) where T : BaseEntity; + + Task FlushAsync(); +} diff --git a/src/Core/Grand.Data/Audit/RepositoryAudit.cs b/src/Core/Grand.Data/Audit/RepositoryAudit.cs new file mode 100644 index 0000000000..c5d4f28f9d --- /dev/null +++ b/src/Core/Grand.Data/Audit/RepositoryAudit.cs @@ -0,0 +1,76 @@ +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, AuditRepositoryAccess access) where T : BaseEntity +{ + private bool Tracking => tracker.IsActive && tracker.IsAudited(typeof(T)); + + public Task Inserted(T entity) + { + return Tracking ? tracker.BeforeWriteAsync(entity.Id, true, access) : Task.CompletedTask; + } + + public Task BeforeFullUpdate(T entity) + { + return Tracking ? tracker.BeforeWriteAsync(entity.Id, false, access) : Task.CompletedTask; + } + + public void AfterFullUpdate(T entity) + { + if (Tracking) + tracker.AfterFullUpdate(entity); + } + + public Task BeforeDelete(T entity) + { + return Tracking ? tracker.BeforeWriteAsync(entity.Id, false, access) : Task.CompletedTask; + } + + /// + /// 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, access, 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, access, 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..59d7dca462 --- /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, new AuditRepositoryAccess(LoadMany)); + } + + //used before installation, when no database is registered yet + public AuditingLiteDBRepository(IAuditInfoProvider auditInfoProvider, IAuditTracker tracker) + : base(auditInfoProvider) + { + _audit = new RepositoryAudit(tracker, new AuditRepositoryAccess(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.BeforeFullUpdate(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.BeforeDelete(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..a47782dc59 --- /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, new AuditRepositoryAccess(LoadMany)); + } + + //used before installation, when no database is registered yet + public AuditingMongoRepository(IAuditInfoProvider auditInfoProvider, IAuditTracker tracker) + : base(auditInfoProvider) + { + _audit = new RepositoryAudit(tracker, new AuditRepositoryAccess(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.BeforeFullUpdate(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.BeforeDelete(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/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..2ca9c706bb --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditKind.cs @@ -0,0 +1,20 @@ +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, + Export = 20, + ImpersonationStart = 30, + ImpersonationEnd = 31, + AuditLogViewed = 40, + AuditLogExported = 41 +} diff --git a/src/Core/Grand.Domain/Audit/AuditSettings.cs b/src/Core/Grand.Domain/Audit/AuditSettings.cs new file mode 100644 index 0000000000..66308fda57 --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditSettings.cs @@ -0,0 +1,16 @@ +using Grand.Domain.Configuration; + +namespace Grand.Domain.Audit; + +public class AuditSettings : ISettings +{ + /// + /// Gets or sets the number of days audit entries are kept + /// + public int RetentionDays { get; set; } = 365; + + /// + /// Gets or sets a value indicating whether staff sign-ins are recorded + /// + public bool LogStaffSignIns { get; set; } = true; +} 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/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.Infrastructure/Audit/AuditDiff.cs b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs new file mode 100644 index 0000000000..10fbd63ae3 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs @@ -0,0 +1,299 @@ +using Grand.Domain.Audit; +using MongoDB.Bson; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Grand.Infrastructure.Audit; + +/// +/// Compares two versions of a stored document and lists the changed fields, provider-independent. +/// +public static partial class AuditDiff +{ + public const int MaxValueLength = 1024; + + private static readonly string[] NameFields = ["Name", "Title", "Email", "OrderNumber", "Code", "SystemName"]; + + [GeneratedRegex("password|salt|hash|secret|api_?key|token|privatekey", RegexOptions.IgnoreCase)] + private static partial Regex SensitiveName(); + + /// + /// Lists the changed leaf fields. is null for an insert, + /// is null for a delete (which lists nothing). + /// + public static IList Compare(string entityType, BsonDocument before, BsonDocument after, + ISet ignoredPaths) + { + var changes = new List(); + if (after == null) + return changes; + + before ??= new BsonDocument(); + if (entityType == "Setting") + { + before = ExpandMetadata(before); + after = ExpandMetadata(after); + } + + CompareDocuments(string.Empty, false, before, after, ignoredPaths, changes); + return changes; + } + + /// + /// A display label for the entity: the first non-empty of Name, Title, Email, OrderNumber, Code, SystemName. + /// + public static string EntityName(BsonDocument document) + { + if (document == null) + return null; + + foreach (var field in NameFields) + if (document.TryGetValue(field, out var value) && value.IsString && !string.IsNullOrEmpty(value.AsString)) + return value.AsString; + + return null; + } + + private static BsonDocument ExpandMetadata(BsonDocument document) + { + if (!document.TryGetValue("Metadata", out var metadata) || !metadata.IsString) + return document; + + BsonDocument parsed; + try + { + parsed = BsonDocument.Parse(metadata.AsString); + } + catch (Exception) + { + //the raw text may hold secrets; a hash still shows whether it changed + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(metadata.AsString)))[..12]; + var masked = document.DeepClone().AsBsonDocument; + masked["Metadata"] = $""; + return masked; + } + + var copy = document.DeepClone().AsBsonDocument; + copy["Metadata"] = parsed; + return copy; + } + + private static void CompareDocuments(string path, bool masked, BsonDocument before, BsonDocument after, + ISet ignoredPaths, List changes) + { + var names = after.Names.Concat(before.Names.Where(n => !after.Contains(n))).ToList(); + foreach (var name in names) + { + if (path.Length == 0 && name == "_id") + continue; + + before.TryGetValue(name, out var b); + after.TryGetValue(name, out var a); + var childPath = path.Length == 0 ? name : $"{path}.{name}"; + CompareValues(childPath, masked || SensitiveName().IsMatch(name), b, a, ignoredPaths, changes); + } + } + + private static void CompareValues(string path, bool masked, BsonValue before, BsonValue after, + ISet ignoredPaths, List changes) + { + if (ignoredPaths.Contains(path)) + return; + if (before == null && after == null) + return; + + //null next to a container compares like a missing side, so its leaves are listed (and masked) one by one + if (before is { IsBsonNull: true } && after is { IsBsonDocument: true } or { IsBsonArray: true }) before = null; + if (after is { IsBsonNull: true } && before is { IsBsonDocument: true } or { IsBsonArray: true }) after = null; + + //a missing side compares as an empty container, so an insert or a removal lists the leaves + if (before == null && after.IsBsonDocument) before = new BsonDocument(); + if (after == null && before.IsBsonDocument) after = new BsonDocument(); + if (before == null && after.IsBsonArray) before = new BsonArray(); + if (after == null && before.IsBsonArray) after = new BsonArray(); + + if ((before == null && after.IsBsonNull) || (after == null && before.IsBsonNull)) + return; + + if (before is { IsBsonDocument: true } && after is { IsBsonDocument: true }) + { + CompareDocuments(path, masked, before.AsBsonDocument, after.AsBsonDocument, ignoredPaths, changes); + return; + } + + if (before is { IsBsonArray: true } && after is { IsBsonArray: true }) + { + CompareArrays(path, masked, before.AsBsonArray, after.AsBsonArray, ignoredPaths, changes); + return; + } + + if (Equals(before, after)) + return; + + AddChange(changes, path, masked || ContainsSensitive(before) || ContainsSensitive(after), Format(before), + Format(after)); + } + + private static void CompareArrays(string path, bool masked, BsonArray before, BsonArray after, + ISet ignoredPaths, List changes) + { + var all = before.Concat(after).ToList(); + if (all.Count == 0) + return; + + if (all.All(v => v.IsBsonDocument && ElementId(v.AsBsonDocument) != null)) + { + var beforeById = ById(before); + var afterById = ById(after); + foreach (var id in afterById.Keys.Concat(beforeById.Keys.Where(k => !afterById.ContainsKey(k)))) + { + var elementPath = $"{path}[id={id}]"; + beforeById.TryGetValue(id, out var b); + afterById.TryGetValue(id, out var a); + if (b != null && a != null) + CompareDocuments(elementPath, masked, b, a, ignoredPaths, changes); + else if (!ignoredPaths.Contains(elementPath)) + AddChange(changes, elementPath, masked || ContainsSensitive(b) || ContainsSensitive(a), + b == null ? null : Summary(b, id), a == null ? null : Summary(a, id)); + } + + return; + } + + //key/value pairs (user fields): matched by key, and masked by the key's name - the leaf is always "Value", + //so a password token or a 2FA secret would otherwise be stored in clear + if (all.All(v => v.IsBsonDocument && ElementKey(v.AsBsonDocument) != null)) + { + var beforeByKey = ByKey(before); + var afterByKey = ByKey(after); + foreach (var key in afterByKey.Keys.Concat(beforeByKey.Keys.Where(k => !afterByKey.ContainsKey(k)))) + { + var elementPath = $"{path}[{key}]"; + var keyMasked = masked || SensitiveName().IsMatch(key); + beforeByKey.TryGetValue(key, out var b); + afterByKey.TryGetValue(key, out var a); + if (b != null && a != null) + CompareDocuments(elementPath, keyMasked, b, a, ignoredPaths, changes); + else if (!ignoredPaths.Contains(elementPath)) + AddChange(changes, elementPath, keyMasked || ContainsSensitive(b) || ContainsSensitive(a), + b == null ? null : PairValue(b), a == null ? null : PairValue(a)); + } + + return; + } + + if (all.All(v => !v.IsBsonDocument && !v.IsBsonArray)) + { + var removed = before.Where(v => !after.Contains(v)).Select(Format).ToList(); + var added = after.Where(v => !before.Contains(v)).Select(Format).ToList(); + if (removed.Count == 0 && added.Count == 0) + return; + + AddChange(changes, path, masked, + removed.Count > 0 ? "removed: " + string.Join(", ", removed) : null, + added.Count > 0 ? "added: " + string.Join(", ", added) : null); + return; + } + + for (var i = 0; i < Math.Max(before.Count, after.Count); i++) + CompareValues($"{path}[{i}]", masked, i < before.Count ? before[i] : null, + i < after.Count ? after[i] : null, ignoredPaths, changes); + } + + private static string ElementId(BsonDocument element) + { + if (element.TryGetValue("_id", out var id) || element.TryGetValue("Id", out id)) + return id.IsBsonNull ? null : id.ToString(); + return null; + } + + //the first element wins when ids repeat; a malformed document must not lose the whole batch + private static Dictionary ById(BsonArray array) + { + var byId = new Dictionary(); + foreach (var element in array.Select(v => v.AsBsonDocument)) + byId.TryAdd(ElementId(element), element); + return byId; + } + + private static bool ContainsSensitive(BsonValue value) + { + return value switch { + BsonDocument document => document.Elements.Any(e => + SensitiveName().IsMatch(e.Name) || ContainsSensitive(e.Value)), + BsonArray array => array.Any(ContainsSensitive), + _ => false + }; + } + + private static string ElementKey(BsonDocument element) + { + if (element.Contains("_id") || element.Contains("Id")) + return null; + if (!element.TryGetValue("Key", out var key) || !key.IsString || string.IsNullOrEmpty(key.AsString)) + return null; + + //the same key can exist once per store + return element.TryGetValue("StoreId", out var storeId) && storeId.IsString && storeId.AsString != "" + ? $"{key.AsString}@{storeId.AsString}" + : key.AsString; + } + + private static Dictionary ByKey(BsonArray array) + { + var byKey = new Dictionary(); + foreach (var element in array.Select(v => v.AsBsonDocument)) + byKey.TryAdd(ElementKey(element), element); + return byKey; + } + + private static string PairValue(BsonDocument element) + { + return element.TryGetValue("Value", out var value) ? Format(value) : element.ToJson(); + } + + private static string Summary(BsonDocument element, string id) + { + return EntityName(element) ?? id; + } + + private static string Format(BsonValue value) + { + if (value == null || value.IsBsonNull) + return null; + if (value.IsString) + return value.AsString; + if (value.IsBsonBinaryData) + return $""; + if (value.IsValidDateTime) + return value.ToUniversalTime().ToString("o"); + if (value.IsBsonDocument || value.IsBsonArray) + return value.ToJson(); + return value.ToString(); + } + + private static void AddChange(List changes, string path, bool masked, string oldValue, + string newValue) + { + if (masked) + { + changes.Add(new AuditChange { Path = path, Masked = true }); + return; + } + + var change = new AuditChange { + Path = path, + OldValue = Truncate(oldValue, out var oldTruncated), + NewValue = Truncate(newValue, out var newTruncated) + }; + change.Truncated = oldTruncated || newTruncated; + changes.Add(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..6364d44bea --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -0,0 +1,306 @@ +using Grand.Data.Audit; +using Grand.Domain; +using Grand.Domain.Audit; +using Grand.Mediator; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; + +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" + ]; + + 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(); + private AuditActor _actor; + + public bool IsActive => _actor != null; + + 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, AuditRepositoryAccess access, + 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(); + + BsonDocument before = null; + if (!isInsert) + try + { + before = (await access.LoadMany([id])).FirstOrDefault()?.ToBsonDocument(); + } + 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 access.LoadMany(ids)).ToDictionary(e => e.Id, e => e.ToBsonDocument()); + + 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; + + lock (_lock) + { + if (_pending.TryGetValue((typeof(T), entity.Id), out var pending)) + pending.AfterHint = entity.ToBsonDocument(); + } + } + + 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 FlushAsync() + { + if (!IsActive) + 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) + Stamp(entry, now); + + 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; + } + + 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.Name, 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(string entityType, string entityId, Pending pending, BsonDocument after) + { + var before = pending.Before; + var entry = new AuditEntry { + EntityType = entityType, + EntityId = entityId, + EntityName = AuditDiff.EntityName(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 StoreIdOf(BsonDocument document) + { + return document != null && document.TryGetValue("StoreId", out var storeId) && storeId.IsString + ? storeId.AsString + : null; + } + + private static bool Touches(string changePath, string writtenPath) + { + return changePath == writtenPath + || changePath.StartsWith(writtenPath + ".", StringComparison.Ordinal) + || changePath.StartsWith(writtenPath + "[", StringComparison.Ordinal); + } + + private void Stamp(AuditEntry entry, DateTime now) + { + entry.CreatedOnUtc = now; + entry.Source = _actor.Source; + entry.ActorCustomerId = _actor.CustomerId; + entry.ActorEmail = _actor.Email; + entry.ImpersonatedCustomerId = _actor.ImpersonatedCustomerId; + entry.StoreId = _actor.StoreId; + entry.VendorId = _actor.VendorId; + entry.IpAddress = _actor.IpAddress; + entry.UserAgent = _actor.UserAgent; + entry.Action = _actor.Action; + entry.OperationId = _actor.OperationId; + } + + private sealed class Pending(BsonDocument before, IReadOnlyCollection paths) + { + public BsonDocument Before { get; } = before; + + /// + /// The paths written in this scope; null means every field + /// + public HashSet Paths { get; private set; } = paths == null ? null : [..paths]; + + public BsonDocument 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/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/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/InstallDataSettings.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs index f232ca2ac7..d1a2a7ac04 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs @@ -1,4 +1,5 @@ using Grand.Domain.Admin; +using Grand.Domain.Audit; using Grand.Domain.Blogs; using Grand.Domain.Catalog; using Grand.Domain.Cms; @@ -63,6 +64,7 @@ await _settingRepository.SaveSetting(new CommonSettings { await _settingRepository.SaveSetting(new SystemSettings { DeleteGuestTaskOlderThanMinutes = 1440 }); + await _settingRepository.SaveSetting(new AuditSettings()); await _settingRepository.SaveSetting(new SecuritySettings { AdminAreaAllowedIpAddresses = null }); 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..a6c3b3e308 --- /dev/null +++ b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs @@ -0,0 +1,18 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.System.ScheduleTasks; +using Grand.Domain.Audit; + +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, AuditSettings auditSettings) : IScheduleTask +{ + public async Task Execute(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var days = Math.Max(1, auditSettings.RetentionDays); + await auditService.DeleteOlderThan(DateTime.UtcNow.AddDays(-days)); + } +} 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..262c511fdb --- /dev/null +++ b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs @@ -0,0 +1,154 @@ +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.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); + } + + 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 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..354ad4720a --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs @@ -0,0 +1,327 @@ +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) + { + return AuditDiff.Compare(entityType, before, after, 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]"); + Assert.IsNull(added.OldValue); + Assert.IsNotNull(added.NewValue); + var removed = changes.Single(c => c.Path == "TierPrices[id=old]"); + Assert.IsNotNull(removed.OldValue); + Assert.IsNull(removed.NewValue); + } + + [TestMethod] + public void Compare_ScalarArray_SetSemantics() + { + 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("removed: a", changes[0].OldValue); + Assert.AreEqual("added: c", changes[0].NewValue); + } + + [TestMethod] + public void Compare_ScalarArray_ReorderOnly_ReturnsEmpty() + { + Assert.AreEqual(0, Compare(new BsonDocument("Stores", new BsonArray { "a", "b" }), + new BsonDocument("Stores", new BsonArray { "b", "a" })).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("UserFields[ImpersonatedCustomerId]", changes.Single().Path); + Assert.AreEqual("c9", changes.Single().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); + + 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_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_NeverStoresBytes() + { + var changes = Compare(new BsonDocument("Binary", new BsonBinaryData([1, 2, 3])), + new BsonDocument("Binary", new BsonBinaryData([1, 2, 3, 4]))); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("", changes[0].OldValue); + Assert.AreEqual("", changes[0].NewValue); + } + + [TestMethod] + public void Compare_Binary_SameBytes_ReturnsEmpty() + { + Assert.AreEqual(0, Compare(new BsonDocument("Binary", new BsonBinaryData([1, 2, 3])), + new BsonDocument("Binary", new BsonBinaryData([1, 2, 3]))).Count); + } + + [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_FallsBackToString() + { + var changes = Compare(new BsonDocument("Metadata", "{oops"), new BsonDocument("Metadata", "{}"), "Setting"); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual("Metadata", changes[0].Path); + } + + [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().Masked); + } + + [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); + } + + [TestMethod] + public void EntityName_PicksFirstPresent() + { + Assert.AreEqual("Blue", AuditDiff.EntityName(new BsonDocument { { "Email", "a@b.c" }, { "Name", "Blue" } })); + Assert.AreEqual("a@b.c", AuditDiff.EntityName(new BsonDocument { { "Name", "" }, { "Email", "a@b.c" } })); + Assert.IsNull(AuditDiff.EntityName(new BsonDocument("Price", 1))); + Assert.IsNull(AuditDiff.EntityName(null)); + } +} 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..e97ed33936 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditFilterIdTests.cs @@ -0,0 +1,82 @@ +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_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..71a5e2cb91 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs @@ -0,0 +1,447 @@ +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 AuditRepositoryAccess _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 = new AuditRepositoryAccess(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); + var failing = new AuditRepositoryAccess(_ => 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()); + } + + 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..acb17714b6 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs @@ -0,0 +1,55 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Domain.Audit; +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 AuditSettings { 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 AuditSettings()); + + await Assert.ThrowsExactlyAsync(() => task.Execute(new CancellationToken(true))); + + _auditService.Verify(s => s.DeleteOlderThan(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Execute_RetentionZero_TreatedAsOneDay() + { + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditSettings { RetentionDays = 0 }); + + await task.Execute(CancellationToken.None); + + var expected = DateTime.UtcNow.AddDays(-1); + Assert.IsTrue(Math.Abs((_cutoff - expected).TotalSeconds) < 5, _cutoff.ToString("o")); + } +} 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..8deb7e2dd7 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs @@ -0,0 +1,301 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.Common.Configuration; +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.Admin.Validators.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, + new Mock().Object, new AuditSettings(), 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); + } + + [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 async Task SettingsValidator_RetentionZero_Invalid() + { + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns("message"); + var validator = new AuditSettingsValidator([], translation.Object); + + Assert.IsFalse((await validator.ValidateAsync(new AuditSettingsModel { RetentionDays = 0 })).IsValid); + Assert.IsTrue((await validator.ValidateAsync(new AuditSettingsModel { RetentionDays = 1 })).IsValid); + } + + [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..a6ea6b565a --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs @@ -0,0 +1,216 @@ +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.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 AuditSettings _settings; + 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); + _settings = new AuditSettings(); + _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, _settings, _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 SignIn_Customer_NotRecorded() + { + await _recorder.SignIn(Buyer); + + Assert.AreEqual(0, _recorded.Count); + } + + [TestMethod] + public async Task SignIn_SettingOff_NotRecorded() + { + _settings.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), CancellationToken.None); + await handler.Handle(new CustomerLoggedOutEvent(Staff), CancellationToken.None); + + recorder.Verify(r => r.SignIn(Staff)); + recorder.Verify(r => r.SignInFailed(Staff, "wrong credentials")); + 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..fcebaa2381 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs @@ -0,0 +1,74 @@ +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); + } + + 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..6660a6af31 --- /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.FlushAsync(), 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.FlushAsync(), 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.FlushAsync(), 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/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..7dc3ca1d60 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml @@ -0,0 +1,95 @@ +@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..0f68f9fe5f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -0,0 +1,327 @@ +using Grand.Business.Core.Interfaces.Common.Audit; +using Grand.Business.Core.Interfaces.Common.Configuration; +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, CSV export and retention settings. Main Admin only. +/// +[PermissionAuthorize(PermissionSystemName.AuditLog)] +public class AuditLogController( + IAuditService auditService, + IAuditEventRecorder auditEventRecorder, + ISettingService settingService, + AuditSettings auditSettings, + 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, + Settings = new AuditSettingsModel { + RetentionDays = auditSettings.RetentionDays, + LogStaffSignIns = auditSettings.LogStaffSignIns + } + }; + 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)).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)).ToList(), + Total = string.IsNullOrEmpty(entityId) ? entries.Count : entries.TotalCount + }); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task Settings([Bind(Prefix = "Settings")] AuditSettingsModel model) + { + if (!ModelState.IsValid) + { + Error(ModelState); + return RedirectToAction("List"); + } + + auditSettings.RetentionDays = model.RetentionDays; + auditSettings.LogStaffSignIns = model.LogStaffSignIns; + await settingService.SaveSetting(auditSettings); + Success(translationService.GetResource("Admin.Configuration.Updated")); + return RedirectToAction("List"); + } + + 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 = model.FromUtc, + ToUtc = 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() + }; + } + + 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; + } + + private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary stores) + { + 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, + Changes = entry.Changes.Select(ToChange).ToList(), + ChangesText = string.Join(" · ", entry.Changes.Select(ToChange).Select(c => + $"{c.Path}: {Shorten(c.OldValue)} → {Shorten(c.NewValue)}")) + }; + } + + 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/Models/AuditLog/AuditEntryModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs new file mode 100644 index 0000000000..41d1b9bd9a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs @@ -0,0 +1,61 @@ +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 IList Changes { get; set; } = new List(); +} + +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..966f528f5b --- /dev/null +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs @@ -0,0 +1,46 @@ +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(); + + public AuditSettingsModel Settings { get; set; } = new(); +} diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditSettingsModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditSettingsModel.cs new file mode 100644 index 0000000000..0f7cf9d1c7 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditSettingsModel.cs @@ -0,0 +1,13 @@ +using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.Models; + +namespace Grand.Web.Admin.Models.AuditLog; + +public class AuditSettingsModel : BaseModel +{ + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.RetentionDays")] + public int RetentionDays { get; set; } + + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.LogStaffSignIns")] + public bool LogStaffSignIns { get; set; } +} diff --git a/src/Web/Grand.Web.Admin/Validators/AuditLog/AuditSettingsValidator.cs b/src/Web/Grand.Web.Admin/Validators/AuditLog/AuditSettingsValidator.cs new file mode 100644 index 0000000000..7d0c125a5f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Validators/AuditLog/AuditSettingsValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Grand.Infrastructure.Validators; +using Grand.Web.Admin.Models.AuditLog; +using Grand.Web.Common.Localization; + +namespace Grand.Web.Admin.Validators.AuditLog; + +public class AuditSettingsValidator : BaseGrandValidator +{ + public AuditSettingsValidator(IEnumerable> validators, + IRequestTranslationService translationService) + : base(validators) + { + RuleFor(x => x.RetentionDays).GreaterThanOrEqualTo(1) + .WithMessage(translationService.GetResource("Admin.System.AuditLog.Fields.RetentionDays.Min")); + } +} 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/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/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..0b76fc1b8e --- /dev/null +++ b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs @@ -0,0 +1,157 @@ +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 Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace Grand.Web.Common.Audit; + +public class AuditEventRecorder( + IAuditService auditService, + AuditSettings auditSettings, + 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}"); + } + + private async Task RecordSignInEvent(AuditKind kind, Customer customer, string reason) + { + try + { + if (!auditSettings.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 + { + var entry = new AuditEntry { + Kind = kind, + ActorCustomerId = actor?.Id, + ActorEmail = actor?.Email, + StoreId = actor?.StaffStoreId, + VendorId = actor?.VendorId + }; + FillRequest(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); + } + } + + private void FillRequest(AuditEntry entry) + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext == null) + return; + + var routeValues = httpContext.Request.RouteValues; + entry.Source = (routeValues["area"] as string) switch { + "Admin" => AuditSource.Admin, + "Store" => AuditSource.Store, + "Vendor" => AuditSource.Vendor, + _ => AuditSource.Storefront + }; + entry.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(); + entry.UserAgent = httpContext.Request.Headers.UserAgent.ToString(); + entry.Action = $"{entry.Source} / {routeValues["controller"]} / {routeValues["action"]}"; + entry.OperationId = 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..2283f51000 --- /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, "wrong credentials"); + } + + 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..81e015ca27 --- /dev/null +++ b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs @@ -0,0 +1,33 @@ +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); +} 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..1b9d1425d8 --- /dev/null +++ b/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs @@ -0,0 +1,80 @@ +using Grand.Web.Common.Audit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System.Collections; +using System.Text.RegularExpressions; + +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 partial 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) + { + var summary = Summarize(context.ActionArguments); + 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)) + 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 || SensitiveName().IsMatch(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); + } + } + + [GeneratedRegex("password|secret|token|apikey", RegexOptions.IgnoreCase)] + private static partial Regex SensitiveName(); +} 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..5adca008a4 --- /dev/null +++ b/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs @@ -0,0 +1,92 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using Grand.Infrastructure; +using Grand.Infrastructure.Audit; +using Microsoft.AspNetCore.Mvc.Filters; +using System.Diagnostics; + +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 +{ + private const string AreaAdmin = "Admin"; + private const string AreaStore = "Store"; + private const string AreaVendor = "Vendor"; + + 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.FlushAsync(); + } + } + + 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"; + } + + var routeValues = context.RouteData.Values; + return new AuditActor( + source.Value, + customerId, + email, + original != null ? current?.Id : null, + staff?.StaffStoreId, + staff?.VendorId, + httpContext.Connection.RemoteIpAddress?.ToString(), + httpContext.Request.Headers.UserAgent.ToString(), + $"{source.Value} / {routeValues["controller"]} / {routeValues["action"]}", + Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier); + } + + private static AuditSource? ResolveSource(ResourceExecutingContext context, bool impersonating) + { + var marked = context.ActionDescriptor.EndpointMetadata?.OfType().LastOrDefault(); + if (marked != null) + return marked.Source; + + return context.RouteData.Values["area"] as string switch { + AreaAdmin => AuditSource.Admin, + AreaStore => AuditSource.Store, + AreaVendor => AuditSource.Vendor, + null or "" when impersonating => 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/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/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index b513afc5ac3b1220d4849191f0ccf0b19a412964..3c0605a476c965acd47e0980ce96a1049fa4ee56 100644 GIT binary patch delta 3571 zcmb7G3v3f*9RIGp-rDQ6cYPckBZDr$Hkpo1%(5_HE3W~GaUh~Hd30+x_F!ALj#mV> zqy`d*LYnL!F&SV;P)rO%5RE|@5ST+?K#XFH4g?`QbR$NL5%vFlw?{{BY^J&N?)(1# z-~avS`o3iM>C&bM7coSP5k6vy2$6({Ibw;35o<(>*dq2La={skpEQyHsUTr+*c~1? zQEp7N^qmkd_;%aMLFtgCaRYRun$(hVl1cpdM&HZ8U#Gg@tsC~|;LsU^pn6G=)Q~W# zN7x`~AYMkavPIei%30YiKL!Qo-;|FtArl_EK_R7NH43Uhfk6_0MVB1OP*B57N*;2K z64H^b0u2dJ*^oU$sU15k7=*qN3!!Ph$qw5diq%XhPdqh+3d6ozd>$0tweuF{B@`8e z=6nIYf~F+La|OwIQilfDBkv2cDG#!8QO;i36wo*R+APimU3QBQoj%tMJFi;Pq3&C& z2xmUGtzu4zb5}nH&((q>N%9$AA*&(fq`U@JZIcD7rX~wgv~8^myxbTO<`tTAsKpW& zfQ<1TKNNJyNpZ;ScdQfP!BK9y10ks+nPXojOUP{I(l~~c3kDUECh{|*S;Zxg^_`p; zM-pnby5K~$Py`P?wwCDofYIz-BP@pM3&s`0(^zO$VXBf1@OGXu8@wHF{F?Zv~( zj^XiL;buV7OS}O0)(B=;nv+nbFKf$3oC?1j;buZsmYZ)WbPEaTy4qJ z?~IN-1O9edYXEV9rQjYNaAd6k?y}$;5Kv47&^9mtOKI@yX*?@K)he)$V0r6McvH;q*LAPrcG!$vX z*FSggOPeTO7N!{0QO`8S<433}%zH2Pp?sq}4(|MGl%Q^!#cdf)6saMep}5R;M9qXf zl^piyDjRPNV&Co?UC=w%=z<$F4bFsYJVR<|o1lc^YGBs^lM8-7r6z&*wjmXkyzg43 zpGevBEGnG1u87c@ryPvK;WoNm&~ub~9=gKDCpVkumj}ig{iGbYWLqB8{b96&^RA!` z7oBlbgW>L*mEbEh=Rx;9vuFyU%L5QfG}W?X(;0r0F9>bfCLeUa<2EM`PGMS6172sb z4}ecr8q?ytRxNszW*x2efm?0QIJDWC+VUH3cF;RoqB=W=ZSWK`1e!Yq6`Cx#Wpr2B z>W14&=-SbUn5D4mSHld;;AeYf0vpxFX>-lMedeE(>UsLSDC?~)+q5d;1lW_-t-sOg5$hzuCSNrb<^PJxAnQUDN z-4`9HQwI@8g$|*gWDR+2Jn~x9DM;2qo7uT(@<_lXXi7EoR`NKYvz+PhPrzUX_jb<; zrdLcd>F5R79r6V5o_C}|-JiS!O&#*$(Xja+@qKK#7fEpF5kES%T?PHJSo{`^06;2_y3M58{25$jiLg)tYFBrCUW!c*aPv>mQ}41o(b-Cl7$s{67V_ z%?8f^uLvg#4X-kQn8BrMJ)~^#^a-si6(pF_&S{#*EHAN)4cJc_u{29DJu~6mN^Tr0 zj+VQ05$m^lRJ`X8y7G-hWC8f@J2pI}@24Tsu+5j^!sPo4^PIW*i`qmp%)0MbpE4ph zENV0Lnc4G!;dSPkQE8dK!>UGe$`nmv(M?W-g*&Wbw5(8b(BRvq=pv87N!>F7M2#)7Pc1l7LFFq7OocV7M>Q~7QQX~4#%cnc+D@?&h?5P qhy}KDy%LOIoqmBu$f|XdQ0pe))=eU 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 + + + Settings + + + 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) + + + Retention (days) + + + Entries older than this are deleted by the 'Audit log retention' task. Changing this value is itself recorded. + + + Keep entries for at least 1 day. + + + Record staff sign-ins + + + Records sign-ins, failed sign-ins and sign-outs of staff accounts. Data changes are always recorded. + + + Insert + + + Update + + + Delete + + + Bulk change + + + Sign-in + + + Sign-in failed + + + Sign-out + + + Export + + + Impersonation started + + + Impersonation ended + + + Audit log viewed + + + Audit log exported + + + Admin + + + Store + + + Vendor + + + API + + + SCIM + + + Storefront + diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 8c2755ebb0..317bf6dfcf 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; @@ -379,13 +380,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", From 16616ddf4b61b026b3364100e3b5085abd59ae74 Mon Sep 17 00:00:00 2001 From: support Date: Wed, 28 Oct 2026 22:29:52 +0100 Subject: [PATCH 2/7] Simplify the audit diff to one flatten-and-compare pass The diff had a separate path for every array shape, null/container pairs and unreadable settings JSON, which made it hard to read and to trust. Both versions are now flattened to "path -> value" and compared: array elements are keyed by id, then by Key (user fields, once per store), then by position, so a user field named like a secret is still masked through its path. Null, empty strings and empty arrays compare like a missing field, a list of scalars is one value, and settings JSON that does not parse is masked whole instead of hashed. IsSensitive is exposed so other audit code can share the same notion of a secret. --- .../Grand.Infrastructure/Audit/AuditDiff.cs | 279 ++++++------------ .../Audit/AuditDiffTests.cs | 33 ++- 2 files changed, 101 insertions(+), 211 deletions(-) diff --git a/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs index 10fbd63ae3..20138a1e83 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs @@ -1,25 +1,34 @@ using Grand.Domain.Audit; using MongoDB.Bson; -using System.Security.Cryptography; -using System.Text; using System.Text.RegularExpressions; namespace Grand.Infrastructure.Audit; /// -/// Compares two versions of a stored document and lists the changed fields, provider-independent. +/// Compares two versions of a stored document field by field, provider-independent: both are flattened to +/// "path → value" and the paths whose values differ are listed. /// public static partial class AuditDiff { public const int MaxValueLength = 1024; + private const string SettingMetadata = "Metadata"; + private static readonly string[] NameFields = ["Name", "Title", "Email", "OrderNumber", "Code", "SystemName"]; [GeneratedRegex("password|salt|hash|secret|api_?key|token|privatekey", RegexOptions.IgnoreCase)] private static partial Regex SensitiveName(); /// - /// Lists the changed leaf fields. is null for an insert, + /// 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, BsonDocument before, BsonDocument after, @@ -29,14 +38,23 @@ public static IList Compare(string entityType, BsonDocument before, if (after == null) return changes; - before ??= new BsonDocument(); - if (entityType == "Setting") + var isSetting = entityType == "Setting"; + var old = Flatten(before, isSetting); + var current = Flatten(after, isSetting); + foreach (var path in current.Keys.Concat(old.Keys.Where(k => !current.ContainsKey(k)))) { - before = ExpandMetadata(before); - after = ExpandMetadata(after); + old.TryGetValue(path, out var oldValue); + current.TryGetValue(path, out var newValue); + if (Equals(oldValue, newValue) || 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) || (isSetting && path == SettingMetadata); + changes.Add(masked + ? new AuditChange { Path = path, Masked = true } + : Change(path, Format(oldValue), Format(newValue))); } - CompareDocuments(string.Empty, false, before, after, ignoredPaths, changes); return changes; } @@ -55,207 +73,83 @@ public static string EntityName(BsonDocument document) return null; } - private static BsonDocument ExpandMetadata(BsonDocument document) + private static Dictionary Flatten(BsonDocument document, bool isSetting) { - if (!document.TryGetValue("Metadata", out var metadata) || !metadata.IsString) - return document; + var leaves = new Dictionary(); + if (document == null) + return leaves; - BsonDocument parsed; - try + foreach (var element in document) { - parsed = BsonDocument.Parse(metadata.AsString); - } - catch (Exception) - { - //the raw text may hold secrets; a hash still shows whether it changed - var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(metadata.AsString)))[..12]; - var masked = document.DeepClone().AsBsonDocument; - masked["Metadata"] = $""; - return masked; - } - - var copy = document.DeepClone().AsBsonDocument; - copy["Metadata"] = parsed; - return copy; - } - - private static void CompareDocuments(string path, bool masked, BsonDocument before, BsonDocument after, - ISet ignoredPaths, List changes) - { - var names = after.Names.Concat(before.Names.Where(n => !after.Contains(n))).ToList(); - foreach (var name in names) - { - if (path.Length == 0 && name == "_id") + if (element.Name == "_id") continue; - before.TryGetValue(name, out var b); - after.TryGetValue(name, out var a); - var childPath = path.Length == 0 ? name : $"{path}.{name}"; - CompareValues(childPath, masked || SensitiveName().IsMatch(name), b, a, ignoredPaths, changes); - } - } - - private static void CompareValues(string path, bool masked, BsonValue before, BsonValue after, - ISet ignoredPaths, List changes) - { - if (ignoredPaths.Contains(path)) - return; - if (before == null && after == null) - return; + var value = element.Value; + //a setting keeps its fields as JSON text, diffed field by field + if (isSetting && element.Name == SettingMetadata && value.IsString) + value = TryParse(value.AsString) ?? value; - //null next to a container compares like a missing side, so its leaves are listed (and masked) one by one - if (before is { IsBsonNull: true } && after is { IsBsonDocument: true } or { IsBsonArray: true }) before = null; - if (after is { IsBsonNull: true } && before is { IsBsonDocument: true } or { IsBsonArray: true }) after = null; - - //a missing side compares as an empty container, so an insert or a removal lists the leaves - if (before == null && after.IsBsonDocument) before = new BsonDocument(); - if (after == null && before.IsBsonDocument) after = new BsonDocument(); - if (before == null && after.IsBsonArray) before = new BsonArray(); - if (after == null && before.IsBsonArray) after = new BsonArray(); - - if ((before == null && after.IsBsonNull) || (after == null && before.IsBsonNull)) - return; - - if (before is { IsBsonDocument: true } && after is { IsBsonDocument: true }) - { - CompareDocuments(path, masked, before.AsBsonDocument, after.AsBsonDocument, ignoredPaths, changes); - return; + Flatten(element.Name, value, leaves); } - if (before is { IsBsonArray: true } && after is { IsBsonArray: true }) - { - CompareArrays(path, masked, before.AsBsonArray, after.AsBsonArray, ignoredPaths, changes); - return; - } - - if (Equals(before, after)) - return; - - AddChange(changes, path, masked || ContainsSensitive(before) || ContainsSensitive(after), Format(before), - Format(after)); + return leaves; } - private static void CompareArrays(string path, bool masked, BsonArray before, BsonArray after, - ISet ignoredPaths, List changes) + private static void Flatten(string path, BsonValue value, Dictionary leaves) { - var all = before.Concat(after).ToList(); - if (all.Count == 0) - return; - - if (all.All(v => v.IsBsonDocument && ElementId(v.AsBsonDocument) != null)) + switch (value) { - var beforeById = ById(before); - var afterById = ById(after); - foreach (var id in afterById.Keys.Concat(beforeById.Keys.Where(k => !afterById.ContainsKey(k)))) - { - var elementPath = $"{path}[id={id}]"; - beforeById.TryGetValue(id, out var b); - afterById.TryGetValue(id, out var a); - if (b != null && a != null) - CompareDocuments(elementPath, masked, b, a, ignoredPaths, changes); - else if (!ignoredPaths.Contains(elementPath)) - AddChange(changes, elementPath, masked || ContainsSensitive(b) || ContainsSensitive(a), - b == null ? null : Summary(b, id), a == null ? null : Summary(a, id)); - } - - return; + //null and empty compare like a missing field, so an insert lists only what was set + case BsonNull or BsonString { Value: "" } 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, value); + break; } - - //key/value pairs (user fields): matched by key, and masked by the key's name - the leaf is always "Value", - //so a password token or a 2FA secret would otherwise be stored in clear - if (all.All(v => v.IsBsonDocument && ElementKey(v.AsBsonDocument) != null)) - { - var beforeByKey = ByKey(before); - var afterByKey = ByKey(after); - foreach (var key in afterByKey.Keys.Concat(beforeByKey.Keys.Where(k => !afterByKey.ContainsKey(k)))) - { - var elementPath = $"{path}[{key}]"; - var keyMasked = masked || SensitiveName().IsMatch(key); - beforeByKey.TryGetValue(key, out var b); - afterByKey.TryGetValue(key, out var a); - if (b != null && a != null) - CompareDocuments(elementPath, keyMasked, b, a, ignoredPaths, changes); - else if (!ignoredPaths.Contains(elementPath)) - AddChange(changes, elementPath, keyMasked || ContainsSensitive(b) || ContainsSensitive(a), - b == null ? null : PairValue(b), a == null ? null : PairValue(a)); - } - - return; - } - - if (all.All(v => !v.IsBsonDocument && !v.IsBsonArray)) - { - var removed = before.Where(v => !after.Contains(v)).Select(Format).ToList(); - var added = after.Where(v => !before.Contains(v)).Select(Format).ToList(); - if (removed.Count == 0 && added.Count == 0) - return; - - AddChange(changes, path, masked, - removed.Count > 0 ? "removed: " + string.Join(", ", removed) : null, - added.Count > 0 ? "added: " + string.Join(", ", added) : null); - return; - } - - for (var i = 0; i < Math.Max(before.Count, after.Count); i++) - CompareValues($"{path}[{i}]", masked, i < before.Count ? before[i] : null, - i < after.Count ? after[i] : null, ignoredPaths, changes); - } - - private static string ElementId(BsonDocument element) - { - if (element.TryGetValue("_id", out var id) || element.TryGetValue("Id", out id)) - return id.IsBsonNull ? null : id.ToString(); - return null; - } - - //the first element wins when ids repeat; a malformed document must not lose the whole batch - private static Dictionary ById(BsonArray array) - { - var byId = new Dictionary(); - foreach (var element in array.Select(v => v.AsBsonDocument)) - byId.TryAdd(ElementId(element), element); - return byId; - } - - private static bool ContainsSensitive(BsonValue value) - { - return value switch { - BsonDocument document => document.Elements.Any(e => - SensitiveName().IsMatch(e.Name) || ContainsSensitive(e.Value)), - BsonArray array => array.Any(ContainsSensitive), - _ => false - }; } - private static string ElementKey(BsonDocument element) + //an array element is matched by its id, else by its key (user fields, once per store), else by position; + //the key is part of the path, so a user field named like a secret is masked + private static string ElementKey(BsonValue element, int index) { - if (element.Contains("_id") || element.Contains("Id")) - return null; - if (!element.TryGetValue("Key", out var key) || !key.IsString || string.IsNullOrEmpty(key.AsString)) - return null; - - //the same key can exist once per store - return element.TryGetValue("StoreId", out var storeId) && storeId.IsString && storeId.AsString != "" + 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 Dictionary ByKey(BsonArray array) + private static BsonDocument TryParse(string json) { - var byKey = new Dictionary(); - foreach (var element in array.Select(v => v.AsBsonDocument)) - byKey.TryAdd(ElementKey(element), element); - return byKey; - } - - private static string PairValue(BsonDocument element) - { - return element.TryGetValue("Value", out var value) ? Format(value) : element.ToJson(); + try + { + return BsonDocument.Parse(json); + } + catch (Exception) + { + return null; + } } - private static string Summary(BsonDocument element, string id) + private static bool IsUnder(string path, string parent) { - return EntityName(element) ?? id; + return path == parent + || path.StartsWith(parent + ".", StringComparison.Ordinal) + || path.StartsWith(parent + "[", StringComparison.Ordinal); } private static string Format(BsonValue value) @@ -268,27 +162,20 @@ private static string Format(BsonValue value) return $""; if (value.IsValidDateTime) return value.ToUniversalTime().ToString("o"); - if (value.IsBsonDocument || value.IsBsonArray) + if (value.IsBsonArray) return value.ToJson(); return value.ToString(); } - private static void AddChange(List changes, string path, bool masked, string oldValue, - string newValue) + private static AuditChange Change(string path, string oldValue, string newValue) { - if (masked) - { - changes.Add(new AuditChange { Path = path, Masked = true }); - return; - } - var change = new AuditChange { Path = path, OldValue = Truncate(oldValue, out var oldTruncated), NewValue = Truncate(newValue, out var newTruncated) }; change.Truncated = oldTruncated || newTruncated; - changes.Add(change); + return change; } private static string Truncate(string value, out bool truncated) diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs index 354ad4720a..00a7bc33de 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs @@ -73,31 +73,32 @@ public void Compare_ArrayOfDocsWithId_AddedAndRemoved() var changes = Compare(before, after); Assert.AreEqual(2, changes.Count); - var added = changes.Single(c => c.Path == "TierPrices[id=x]"); + var added = changes.Single(c => c.Path == "TierPrices[id=x].Quantity"); Assert.IsNull(added.OldValue); - Assert.IsNotNull(added.NewValue); - var removed = changes.Single(c => c.Path == "TierPrices[id=old]"); - Assert.IsNotNull(removed.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_SetSemantics() + 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("removed: a", changes[0].OldValue); - Assert.AreEqual("added: c", changes[0].NewValue); + Assert.AreEqual("[\"a\", \"b\"]", changes[0].OldValue); + Assert.AreEqual("[\"b\", \"c\"]", changes[0].NewValue); } [TestMethod] - public void Compare_ScalarArray_ReorderOnly_ReturnsEmpty() + public void Compare_EmptyStringAndEmptyArray_LikeMissing() { - Assert.AreEqual(0, Compare(new BsonDocument("Stores", new BsonArray { "a", "b" }), - new BsonDocument("Stores", new BsonArray { "b", "a" })).Count); + var before = new BsonDocument { { "Name", "" }, { "Stores", new BsonArray() } }; + + Assert.AreEqual(0, Compare(before, new BsonDocument()).Count); } [TestMethod] @@ -138,8 +139,7 @@ public void Compare_KeyValueArray_AddedKey_ShowsValue() var changes = Compare(new BsonDocument("UserFields", new BsonArray()), new BsonDocument("UserFields", new BsonArray { UserField("ImpersonatedCustomerId", "c9") })); - Assert.AreEqual("UserFields[ImpersonatedCustomerId]", changes.Single().Path); - Assert.AreEqual("c9", changes.Single().NewValue); + Assert.AreEqual("c9", changes.Single(c => c.Path == "UserFields[ImpersonatedCustomerId].Value").NewValue); } [TestMethod] @@ -152,7 +152,8 @@ public void Compare_KeyValueArray_SensitiveKey_Masked() var changes = Compare(before, after); - Assert.AreEqual(2, changes.Count); + //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); @@ -230,12 +231,13 @@ public void Compare_SettingMetadata_SecretMasked() } [TestMethod] - public void Compare_SettingMetadata_InvalidJson_FallsBackToString() + 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] @@ -287,7 +289,8 @@ public void Compare_KeyValueWithoutValue_NestedSecretNotStored() new BsonDocument { { "Key", "Gateway" }, { "Data", new BsonDocument("ApiKey", "k") } } })); - Assert.IsTrue(changes.Single().Masked); + Assert.IsTrue(changes.Single(c => c.Path == "Pairs[Gateway].Data.ApiKey").Masked); + Assert.IsFalse(changes.Any(c => c.NewValue == "k")); } [TestMethod] From 26150973e092d1d4ac21b3c5081c74a89712f26d Mon Sep 17 00:00:00 2001 From: support Date: Wed, 28 Oct 2026 22:29:53 +0100 Subject: [PATCH 3/7] Fix audit trail review findings - Writes made by AuditEntryCreated handlers are not tracked (they are not staff changes and would publish again), and the tracker stops at the end of the request scope instead of queueing writes nobody flushes. - The From/To filter is UTC as labelled; it bound as Unspecified and the driver shifted it by the server's offset. - The store filter also matches the store of the changed record, so Main Admin's changes to a store's data are found under that store. - Logging an export never fails the export, and indexers are skipped. - A failed sign-in carries its reason, so a wrong 2FA code is no longer recorded as wrong credentials. - The CSV export pages below the last time it read instead of skipping and counting the whole result on every page. - Grid rows no longer carry change values the list does not show; the entity type filter is cached. - The secret-name regex and the request description (source, action, operation) are shared, so events of an API or SCIM request agree with its data changes. --- .../Services/Audit/AuditService.cs | 33 ++++++++--- .../Customers/CustomerLoginFailedEvent.cs | 10 ++++ .../Common/Audit/AuditEntrySearch.cs | 4 ++ src/Core/Grand.Data/Audit/AuditActor.cs | 17 +++++- src/Core/Grand.Data/Audit/IAuditTracker.cs | 5 ++ .../Audit/AuditTracker.cs | 36 ++++++------ .../Caching/Constants/CommonCacheKey.cs | 9 +++ .../Services/Audit/AuditServiceTests.cs | 39 ++++++++++++- .../Audit/AuditTrackerTests.cs | 56 +++++++++++++++++++ .../Controllers/AuditLogControllerTests.cs | 20 +++++++ .../Audit/AuditEventRecorderTests.cs | 20 ++++++- .../Filters/AuditExportAttributeTests.cs | 29 ++++++++++ .../Filters/AuditScopeFilterTests.cs | 6 +- .../Controllers/AuditLogController.cs | 24 +++++--- .../Models/AuditLog/AuditEntryModel.cs | 1 - .../Controllers/BaseLoginController.cs | 3 +- .../Validators/Common/LoginValidator.cs | 2 +- .../Audit/AuditEventRecorder.cs | 36 +++--------- .../Grand.Web.Common/Audit/AuditRequest.cs | 48 ++++++++++++++++ .../Audit/AuditSignInEventHandler.cs | 2 +- .../Filters/AuditExportAttribute.cs | 24 +++++--- .../Filters/AuditScopeFilter.cs | 39 +++---------- .../Controllers/AccountController.cs | 3 +- .../Validators/Customer/LoginValidator.cs | 2 +- 24 files changed, 356 insertions(+), 112 deletions(-) create mode 100644 src/Web/Grand.Web.Common/Audit/AuditRequest.cs diff --git a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs index 9234929052..088b215293 100644 --- a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs +++ b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs @@ -4,6 +4,8 @@ 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; @@ -12,10 +14,14 @@ namespace Grand.Business.Common.Services.Audit; public class AuditService( IRepository repository, IAuditEntryWriter writer, - IMediator mediator) : IAuditService + 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) { @@ -45,22 +51,35 @@ public async IAsyncEnumerable StreamEntries(AuditEntrySearch search, OperationId = search.OperationId }; - for (var pageIndex = 0;; pageIndex++) + //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.PagedAsync(Query(bounded), pageIndex, StreamPageSize, cancellationToken); + 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 async Task> GetEntityTypes() + public Task> GetEntityTypes() { - var types = await repository.ToListAsync(repository.Table.Select(x => x.EntityType).Distinct()); - return types.Where(t => !string.IsNullOrEmpty(t)).OrderBy(t => t, StringComparer.Ordinal).ToList(); + 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) @@ -110,7 +129,7 @@ private IQueryable Query(AuditEntrySearch search) } if (!string.IsNullOrEmpty(search.StoreId)) - query = query.Where(x => x.StoreId == 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); 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 index b283650a87..0069d0d5c0 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs @@ -19,6 +19,10 @@ public class AuditEntrySearch /// 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/Core/Grand.Data/Audit/AuditActor.cs b/src/Core/Grand.Data/Audit/AuditActor.cs index 105bf029e9..1edc41a91b 100644 --- a/src/Core/Grand.Data/Audit/AuditActor.cs +++ b/src/Core/Grand.Data/Audit/AuditActor.cs @@ -15,4 +15,19 @@ public record AuditActor( string IpAddress, string UserAgent, string Action, - string OperationId); + 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/IAuditTracker.cs b/src/Core/Grand.Data/Audit/IAuditTracker.cs index f9487c2a0c..d397c0264e 100644 --- a/src/Core/Grand.Data/Audit/IAuditTracker.cs +++ b/src/Core/Grand.Data/Audit/IAuditTracker.cs @@ -29,4 +29,9 @@ Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryAccess acce 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.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 6364d44bea..0360ff03ac 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -33,9 +33,13 @@ private readonly Dictionary, Task _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; + public bool IsActive => _actor != null && !_publishing.Value; public void Activate(AuditActor actor) { @@ -123,9 +127,16 @@ public void RecordBulk(string operation, string filter) where T : BaseEntity } } + 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 (!IsActive) + if (_actor == null) return; List> pending; @@ -153,7 +164,10 @@ public async Task FlushAsync() var now = DateTime.UtcNow; foreach (var entry in entries) - Stamp(entry, now); + { + entry.CreatedOnUtc = now; + _actor.Stamp(entry); + } await writer.InsertManyAsync(entries); } @@ -164,6 +178,7 @@ public async Task FlushAsync() return; } + _publishing.Value = true; foreach (var entry in entries) try { @@ -263,21 +278,6 @@ private static bool Touches(string changePath, string writtenPath) || changePath.StartsWith(writtenPath + "[", StringComparison.Ordinal); } - private void Stamp(AuditEntry entry, DateTime now) - { - entry.CreatedOnUtc = now; - entry.Source = _actor.Source; - entry.ActorCustomerId = _actor.CustomerId; - entry.ActorEmail = _actor.Email; - entry.ImpersonatedCustomerId = _actor.ImpersonatedCustomerId; - entry.StoreId = _actor.StoreId; - entry.VendorId = _actor.VendorId; - entry.IpAddress = _actor.IpAddress; - entry.UserAgent = _actor.UserAgent; - entry.Action = _actor.Action; - entry.OperationId = _actor.OperationId; - } - private sealed class Pending(BsonDocument before, IReadOnlyCollection paths) { public BsonDocument Before { get; } = before; 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/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs index 262c511fdb..954327d5f2 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs @@ -4,6 +4,9 @@ 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; @@ -25,7 +28,8 @@ public async Task Init() await _repository.ClearAsync(); _writer = new Mock(); _mediator = new Mock(); - _service = new AuditService(_repository, _writer.Object, _mediator.Object); + _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, @@ -109,6 +113,39 @@ public async Task StreamEntries_YieldsAllAcrossPages() 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() { diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs index 71a5e2cb91..4eeffe6800 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs @@ -422,6 +422,62 @@ public async Task FullUpdate_ThenPartial_ReReadsAtFlush() 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); + } + public class TrackedThing : BaseEntity { public string Name { get; set; } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs index 8deb7e2dd7..22813e6bf5 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs @@ -98,6 +98,26 @@ public async Task List_Post_MapsFiltersToSearch() 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] diff --git a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs index a6ea6b565a..f5a0676835 100644 --- a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs @@ -4,6 +4,7 @@ using Grand.Domain.Audit; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Audit; using Grand.Web.Common.Audit; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; @@ -81,6 +82,21 @@ public async Task SignIn_Storefront_SourceStorefront() Assert.AreEqual(AuditSource.Storefront, _recorded.Single().Source); } + [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() { @@ -206,11 +222,11 @@ public async Task SignInEventHandler_ForwardsEvents() var handler = new AuditSignInEventHandler(recorder.Object); await handler.Handle(new CustomerLoggedInEvent(Staff, null, "s1", null), CancellationToken.None); - await handler.Handle(new CustomerLoginFailedEvent(Staff), 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 credentials")); + 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 index fcebaa2381..777af00391 100644 --- a/src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Filters/AuditExportAttributeTests.cs @@ -63,6 +63,35 @@ public async Task ActionFailed_NotRecorded() _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; } diff --git a/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs b/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs index 6660a6af31..387ac7c546 100644 --- a/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Filters/AuditScopeFilterTests.cs @@ -75,7 +75,7 @@ public async Task StorefrontRequest_NotActivated() await Run(null); _tracker.Verify(t => t.Activate(It.IsAny()), Times.Never); - _tracker.Verify(t => t.FlushAsync(), Times.Never); + _tracker.Verify(t => t.CompleteAsync(), Times.Never); } [TestMethod] @@ -93,7 +93,7 @@ public async Task AdminArea_Authenticated_ActivatedWithActor() Assert.AreEqual("UA", _activated.UserAgent); Assert.AreEqual("trace-1", _activated.OperationId); Assert.IsNull(_activated.ImpersonatedCustomerId); - _tracker.Verify(t => t.FlushAsync(), Times.Once); + _tracker.Verify(t => t.CompleteAsync(), Times.Once); } [TestMethod] @@ -163,7 +163,7 @@ public async Task ActionThrows_FlushStillCalled() { await Assert.ThrowsExactlyAsync(() => Run("Admin", throws: true)); - _tracker.Verify(t => t.FlushAsync(), Times.Once); + _tracker.Verify(t => t.CompleteAsync(), Times.Once); } [TestMethod] diff --git a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs index 0f68f9fe5f..950e305c16 100644 --- a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -71,7 +71,7 @@ public async Task List(DataSourceRequest command, AuditLogListMod 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)).ToList(), + Data = entries.Select(e => ToRow(e, stores, false)).ToList(), Total = entries.TotalCount }); } @@ -154,7 +154,7 @@ public async Task HistoryList(DataSourceRequest command, string e : await auditService.GetEntries(search, command.Page - 1, HistoryPageSize); var stores = await StoreNames(); return Json(new DataSourceResult { - Data = entries.Select(e => ToRow(e, stores)).ToList(), + Data = entries.Select(e => ToRow(e, stores, true)).ToList(), Total = string.IsNullOrEmpty(entityId) ? entries.Count : entries.TotalCount }); } @@ -199,8 +199,8 @@ private async Task PrepareLists(AuditLogListModel model) private static AuditEntrySearch ToSearch(AuditLogListModel model) { return new AuditEntrySearch { - FromUtc = model.FromUtc, - ToUtc = model.ToUtc?.Date.AddDays(1).AddTicks(-1), + FromUtc = AsUtc(model.FromUtc), + ToUtc = AsUtc(model.ToUtc?.Date.AddDays(1).AddTicks(-1)), ActorEmail = model.ActorEmail?.Trim(), Source = model.Source, Kind = model.Kind, @@ -211,6 +211,12 @@ private static AuditEntrySearch ToSearch(AuditLogListModel model) }; } + //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); @@ -225,7 +231,8 @@ private string AppliesTo(AuditEntry entry, IDictionary stores) return stores.TryGetValue(entry.EntityStoreId, out var name) ? name : entry.EntityStoreId; } - private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary stores) + //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); @@ -238,9 +245,10 @@ private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary s What = string.Join(" ", new[] { entry.EntityType, entry.EntityName }.Where(s => !string.IsNullOrEmpty(s))) + (appliesTo == null ? "" : $" ({appliesTo})"), ChangedFields = fields.Length > ChangedFieldsLength ? fields[..ChangedFieldsLength] + "…" : fields, - Changes = entry.Changes.Select(ToChange).ToList(), - ChangesText = string.Join(" · ", entry.Changes.Select(ToChange).Select(c => - $"{c.Path}: {Shorten(c.OldValue)} → {Shorten(c.NewValue)}")) + ChangesText = withValues + ? string.Join(" · ", entry.Changes.Select(ToChange).Select(c => + $"{c.Path}: {Shorten(c.OldValue)} → {Shorten(c.NewValue)}")) + : null }; } diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs index 41d1b9bd9a..c72544c3e6 100644 --- a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs @@ -19,7 +19,6 @@ public class AuditEntryRowModel /// "Path: before → after" of every change, shortened, for the change history popup /// public string ChangesText { get; set; } - public IList Changes { get; set; } = new List(); } public class AuditChangeModel 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/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.Common/Audit/AuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs index 0b76fc1b8e..5987da5d7a 100644 --- a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs @@ -5,7 +5,6 @@ using Grand.Infrastructure; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using System.Diagnostics; namespace Grand.Web.Common.Audit; @@ -119,14 +118,14 @@ private async Task Record(AuditKind kind, Customer actor, Action fil { try { - var entry = new AuditEntry { - Kind = kind, - ActorCustomerId = actor?.Id, - ActorEmail = actor?.Email, - StoreId = actor?.StaffStoreId, - VendorId = actor?.VendorId - }; - FillRequest(entry); + //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); } @@ -135,23 +134,4 @@ private async Task Record(AuditKind kind, Customer actor, Action fil logger.LogError(ex, "Audit {Kind} of customer {CustomerId} was not recorded", kind, actor?.Id); } } - - private void FillRequest(AuditEntry entry) - { - var httpContext = httpContextAccessor.HttpContext; - if (httpContext == null) - return; - - var routeValues = httpContext.Request.RouteValues; - entry.Source = (routeValues["area"] as string) switch { - "Admin" => AuditSource.Admin, - "Store" => AuditSource.Store, - "Vendor" => AuditSource.Vendor, - _ => AuditSource.Storefront - }; - entry.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(); - entry.UserAgent = httpContext.Request.Headers.UserAgent.ToString(); - entry.Action = $"{entry.Source} / {routeValues["controller"]} / {routeValues["action"]}"; - entry.OperationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier; - } } 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 index 2283f51000..cf5c9dee49 100644 --- a/src/Web/Grand.Web.Common/Audit/AuditSignInEventHandler.cs +++ b/src/Web/Grand.Web.Common/Audit/AuditSignInEventHandler.cs @@ -19,7 +19,7 @@ public Task Handle(CustomerLoggedInEvent notification, CancellationToken cancell public Task Handle(CustomerLoginFailedEvent notification, CancellationToken cancellationToken) { - return recorder.SignInFailed(notification.Customer, "wrong credentials"); + return recorder.SignInFailed(notification.Customer, notification.Reason); } public Task Handle(CustomerLoggedOutEvent notification, CancellationToken cancellationToken) diff --git a/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs b/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs index 1b9d1425d8..7a921e6674 100644 --- a/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs +++ b/src/Web/Grand.Web.Common/Filters/AuditExportAttribute.cs @@ -1,16 +1,16 @@ +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; -using System.Text.RegularExpressions; 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 partial class AuditExportAttribute : TypeFilterAttribute +public class AuditExportAttribute : TypeFilterAttribute { public AuditExportAttribute(string entityType) : base(typeof(AuditExportFilter)) { @@ -23,7 +23,17 @@ public class AuditExportFilter(string entityType, IAuditEventRecorder recorder) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - var summary = Summarize(context.ActionArguments); + //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); @@ -39,7 +49,8 @@ private static string Summarize(IDictionary arguments) 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)) + foreach (var property in value.GetType().GetProperties() + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)) Add(parts, property.Name, property.GetValue(value)); } @@ -49,7 +60,7 @@ private static string Summarize(IDictionary arguments) private static void Add(List parts, string name, object value) { - if (value == null || SensitiveName().IsMatch(name)) + if (value == null || AuditDiff.IsSensitive(name)) return; var type = value.GetType(); @@ -74,7 +85,4 @@ private static bool IsSimple(Type type) || type == typeof(DateTime) || type == typeof(Guid); } } - - [GeneratedRegex("password|secret|token|apikey", RegexOptions.IgnoreCase)] - private static partial Regex SensitiveName(); } diff --git a/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs b/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs index 5adca008a4..b12b28d486 100644 --- a/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs +++ b/src/Web/Grand.Web.Common/Filters/AuditScopeFilter.cs @@ -1,9 +1,8 @@ using Grand.Data.Audit; using Grand.Domain.Audit; using Grand.Infrastructure; -using Grand.Infrastructure.Audit; +using Grand.Web.Common.Audit; using Microsoft.AspNetCore.Mvc.Filters; -using System.Diagnostics; namespace Grand.Web.Common.Filters; @@ -14,10 +13,6 @@ namespace Grand.Web.Common.Filters; /// public class AuditScopeFilter(IAuditTracker tracker, IContextAccessor contextAccessor) : IAsyncResourceFilter { - private const string AreaAdmin = "Admin"; - private const string AreaStore = "Store"; - private const string AreaVendor = "Vendor"; - public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next) { var actor = ResolveActor(context); @@ -34,7 +29,7 @@ public async Task OnResourceExecutionAsync(ResourceExecutingContext context, Res } finally { - await tracker.FlushAsync(); + await tracker.CompleteAsync(); } } @@ -61,32 +56,16 @@ private AuditActor ResolveActor(ResourceExecutingContext context) email = "SCIM"; } - var routeValues = context.RouteData.Values; - return new AuditActor( - source.Value, - customerId, - email, - original != null ? current?.Id : null, - staff?.StaffStoreId, - staff?.VendorId, - httpContext.Connection.RemoteIpAddress?.ToString(), - httpContext.Request.Headers.UserAgent.ToString(), - $"{source.Value} / {routeValues["controller"]} / {routeValues["action"]}", - Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier); + 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 marked = context.ActionDescriptor.EndpointMetadata?.OfType().LastOrDefault(); - if (marked != null) - return marked.Source; - - return context.RouteData.Values["area"] as string switch { - AreaAdmin => AuditSource.Admin, - AreaStore => AuditSource.Store, - AreaVendor => AuditSource.Vendor, - null or "" when impersonating => AuditSource.Storefront, - _ => null - }; + 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/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 317bf6dfcf..b03f497093 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -350,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(); } 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) { From 3260956b87495c9986d41adae96d2ff719a8efdc Mon Sep 17 00:00:00 2001 From: support Date: Wed, 28 Oct 2026 22:36:06 +0100 Subject: [PATCH 4/7] Keep BSON in the data layer for audit snapshots The tracker and the diff in Grand.Infrastructure worked on BsonDocument, so audit logic above the data layer knew the storage format. AuditSnapshot in Grand.Data now turns an entity into "path -> value" next to the repositories that already use BSON, and the tracker and the diff handle plain dictionaries. Binary content is not compared: a written binary field is listed as changed. --- src/Core/Grand.Data/Audit/AuditSnapshot.cs | 105 +++++++++++++++ .../Grand.Infrastructure/Audit/AuditDiff.cs | 125 +++--------------- .../Audit/AuditTracker.cs | 36 ++--- .../Audit/AuditDiffTests.cs | 30 ++--- 4 files changed, 153 insertions(+), 143 deletions(-) create mode 100644 src/Core/Grand.Data/Audit/AuditSnapshot.cs 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.Infrastructure/Audit/AuditDiff.cs b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs index 20138a1e83..657879c1a9 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs @@ -1,19 +1,16 @@ +using Grand.Data.Audit; using Grand.Domain.Audit; -using MongoDB.Bson; using System.Text.RegularExpressions; namespace Grand.Infrastructure.Audit; /// -/// Compares two versions of a stored document field by field, provider-independent: both are flattened to -/// "path → value" and the paths whose values differ are listed. +/// Compares two snapshots of an entity (see ) and lists the paths whose values differ /// public static partial class AuditDiff { public const int MaxValueLength = 1024; - private const string SettingMetadata = "Metadata"; - private static readonly string[] NameFields = ["Name", "Title", "Email", "OrderNumber", "Code", "SystemName"]; [GeneratedRegex("password|salt|hash|secret|api_?key|token|privatekey", RegexOptions.IgnoreCase)] @@ -31,28 +28,27 @@ public static bool IsSensitive(string name) /// Lists the changed fields. is null for an insert, /// is null for a delete (which lists nothing). /// - public static IList Compare(string entityType, BsonDocument before, BsonDocument after, - ISet ignoredPaths) + public static IList Compare(string entityType, IDictionary before, + IDictionary after, ISet ignoredPaths) { var changes = new List(); if (after == null) return changes; - var isSetting = entityType == "Setting"; - var old = Flatten(before, isSetting); - var current = Flatten(after, isSetting); - foreach (var path in current.Keys.Concat(old.Keys.Where(k => !current.ContainsKey(k)))) + before ??= new Dictionary(); + foreach (var path in after.Keys.Concat(before.Keys.Where(k => !after.ContainsKey(k)))) { - old.TryGetValue(path, out var oldValue); - current.TryGetValue(path, out var newValue); - if (Equals(oldValue, newValue) || ignoredPaths.Any(p => IsUnder(path, p))) + //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) || (isSetting && path == SettingMetadata); + var masked = IsSensitive(path) || (entityType == "Setting" && path == AuditSnapshot.SettingMetadata); changes.Add(masked ? new AuditChange { Path = path, Masked = true } - : Change(path, Format(oldValue), Format(newValue))); + : Change(path, oldValue, newValue)); } return changes; @@ -61,88 +57,14 @@ public static IList Compare(string entityType, BsonDocument before, /// /// A display label for the entity: the first non-empty of Name, Title, Email, OrderNumber, Code, SystemName. /// - public static string EntityName(BsonDocument document) - { - if (document == null) - return null; - - foreach (var field in NameFields) - if (document.TryGetValue(field, out var value) && value.IsString && !string.IsNullOrEmpty(value.AsString)) - return value.AsString; - - return null; - } - - private static Dictionary Flatten(BsonDocument document, bool isSetting) - { - var leaves = new Dictionary(); - if (document == null) - return leaves; - - foreach (var element in document) - { - if (element.Name == "_id") - continue; - - var value = element.Value; - //a setting keeps its fields as JSON text, diffed field by field - 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) - { - //null and empty compare like a missing field, so an insert lists only what was set - case BsonNull or BsonString { Value: "" } 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, value); - break; - } - } - - //an array element is matched by its id, else by its key (user fields, once per store), else by position; - //the key is part of the path, so a user field named like a secret is masked - private static string ElementKey(BsonValue element, int index) + public static string EntityName(IDictionary snapshot) { - 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; + return snapshot == null ? null : NameFields.Select(field => ValueOf(snapshot, field)).FirstOrDefault(v => v != null); } - private static BsonDocument TryParse(string json) + private static string ValueOf(IDictionary snapshot, string path) { - try - { - return BsonDocument.Parse(json); - } - catch (Exception) - { - return null; - } + return snapshot.TryGetValue(path, out var value) && !string.IsNullOrEmpty(value) ? value : null; } private static bool IsUnder(string path, string parent) @@ -152,21 +74,6 @@ private static bool IsUnder(string path, string parent) || path.StartsWith(parent + "[", StringComparison.Ordinal); } - private static string Format(BsonValue value) - { - if (value == null || value.IsBsonNull) - return null; - if (value.IsString) - return value.AsString; - if (value.IsBsonBinaryData) - return $""; - if (value.IsValidDateTime) - return value.ToUniversalTime().ToString("o"); - if (value.IsBsonArray) - return value.ToJson(); - return value.ToString(); - } - private static AuditChange Change(string path, string oldValue, string newValue) { var change = new AuditChange { diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 0360ff03ac..66ac98457d 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -3,7 +3,6 @@ using Grand.Domain.Audit; using Grand.Mediator; using Microsoft.Extensions.Logging; -using MongoDB.Bson; namespace Grand.Infrastructure.Audit; @@ -28,7 +27,7 @@ public class AuditTracker(IAuditEntryWriter writer, IMediator mediator, ILogger< private readonly List _bulk = []; - private readonly Dictionary, Task>>> + private readonly Dictionary, Task>>>> _loaders = new(); private readonly object _lock = new(); @@ -75,11 +74,12 @@ public async Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryA if (flush) await FlushAsync(); - BsonDocument before = null; + IDictionary before = null; if (!isInsert) try { - before = (await access.LoadMany([id])).FirstOrDefault()?.ToBsonDocument(); + var stored = (await access.LoadMany([id])).FirstOrDefault(); + before = stored == null ? null : AuditSnapshot.Take(stored); } catch (Exception ex) { @@ -91,7 +91,7 @@ public async Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryA lock (_lock) { _loaders[typeof(T)] = async ids => - (await access.LoadMany(ids)).ToDictionary(e => e.Id, e => e.ToBsonDocument()); + (await access.LoadMany(ids)).ToDictionary(e => e.Id, e => AuditSnapshot.Take(e)); if (_pending.TryGetValue(key, out var existing)) existing.Merge(writtenPaths); @@ -108,7 +108,7 @@ public void AfterFullUpdate(T entity) where T : BaseEntity lock (_lock) { if (_pending.TryGetValue((typeof(T), entity.Id), out var pending)) - pending.AfterHint = entity.ToBsonDocument(); + pending.AfterHint = AuditSnapshot.Take(entity); } } @@ -141,7 +141,7 @@ public async Task FlushAsync() List> pending; List bulk; - Dictionary, Task>>> loaders; + Dictionary, Task>>>> loaders; lock (_lock) { if (_pending.Count == 0 && _bulk.Count == 0) @@ -149,7 +149,7 @@ public async Task FlushAsync() pending = _pending.ToList(); bulk = _bulk.ToList(); - loaders = new Dictionary, Task>>>(_loaders); + loaders = new Dictionary, Task>>>>(_loaders); _pending.Clear(); _bulk.Clear(); } @@ -192,7 +192,7 @@ public async Task FlushAsync() private static async Task> BuildEntries( List> pending, - Dictionary, Task>>> loaders) + Dictionary, Task>>>> loaders) { var entries = new List(); foreach (var group in pending.GroupBy(p => p.Key.Type)) @@ -200,7 +200,7 @@ private static async Task> BuildEntries( 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(); + : new Dictionary>(); foreach (var item in group) { @@ -228,7 +228,8 @@ private static async Task> BuildEntries( return entries; } - private static AuditEntry Build(string entityType, string entityId, Pending pending, BsonDocument after) + private static AuditEntry Build(string entityType, string entityId, Pending pending, + IDictionary after) { var before = pending.Before; var entry = new AuditEntry { @@ -264,11 +265,10 @@ private static AuditEntry Build(string entityType, string entityId, Pending pend return entry; } - private static string StoreIdOf(BsonDocument document) + //"" is a record of all stores, null one without a store + private static string StoreIdOf(IDictionary snapshot) { - return document != null && document.TryGetValue("StoreId", out var storeId) && storeId.IsString - ? storeId.AsString - : null; + return snapshot != null && snapshot.TryGetValue("StoreId", out var storeId) ? storeId : null; } private static bool Touches(string changePath, string writtenPath) @@ -278,16 +278,16 @@ private static bool Touches(string changePath, string writtenPath) || changePath.StartsWith(writtenPath + "[", StringComparison.Ordinal); } - private sealed class Pending(BsonDocument before, IReadOnlyCollection paths) + private sealed class Pending(IDictionary before, IReadOnlyCollection paths) { - public BsonDocument Before { get; } = before; + 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]; - public BsonDocument AfterHint { get; set; } + public IDictionary AfterHint { get; set; } public void Merge(IReadOnlyCollection writtenPaths) { diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs index 00a7bc33de..34f41ffc92 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs @@ -1,3 +1,4 @@ +using Grand.Data.Audit; using Grand.Domain.Audit; using Grand.Infrastructure.Audit; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -13,7 +14,9 @@ public class AuditDiffTests private static IList Compare(BsonDocument before, BsonDocument after, string entityType = "Product", ISet ignored = null) { - return AuditDiff.Compare(entityType, before, after, ignored ?? NoIgnored); + var isSetting = entityType == "Setting"; + return AuditDiff.Compare(entityType, AuditSnapshot.Flatten(before, isSetting), + AuditSnapshot.Flatten(after, isSetting), ignored ?? NoIgnored); } [TestMethod] @@ -190,21 +193,14 @@ public void Compare_LongValue_Truncated() } [TestMethod] - public void Compare_Binary_NeverStoresBytes() + public void Compare_Binary_NotCompared_ListedAsChanged() { - var changes = Compare(new BsonDocument("Binary", new BsonBinaryData([1, 2, 3])), - new BsonDocument("Binary", new BsonBinaryData([1, 2, 3, 4]))); + 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_Binary_SameBytes_ReturnsEmpty() - { - Assert.AreEqual(0, Compare(new BsonDocument("Binary", new BsonBinaryData([1, 2, 3])), - new BsonDocument("Binary", new BsonBinaryData([1, 2, 3]))).Count); + Assert.AreEqual("", changes[0].OldValue); + Assert.AreEqual("", changes[0].NewValue); } [TestMethod] @@ -322,9 +318,11 @@ public void Compare_AfterNull_DeleteReturnsEmptyChanges() [TestMethod] public void EntityName_PicksFirstPresent() { - Assert.AreEqual("Blue", AuditDiff.EntityName(new BsonDocument { { "Email", "a@b.c" }, { "Name", "Blue" } })); - Assert.AreEqual("a@b.c", AuditDiff.EntityName(new BsonDocument { { "Name", "" }, { "Email", "a@b.c" } })); - Assert.IsNull(AuditDiff.EntityName(new BsonDocument("Price", 1))); + string Name(BsonDocument document) => AuditDiff.EntityName(AuditSnapshot.Flatten(document, false)); + + Assert.AreEqual("Blue", Name(new BsonDocument { { "Email", "a@b.c" }, { "Name", "Blue" } })); + Assert.AreEqual("a@b.c", Name(new BsonDocument { { "Name", "" }, { "Email", "a@b.c" } })); + Assert.IsNull(Name(new BsonDocument("Price", 1))); Assert.IsNull(AuditDiff.EntityName(null)); } } From 78104ac467aa1a7996e2803000fd6370ea4267c6 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 00:07:29 +0100 Subject: [PATCH 5/7] Close SOC 2 / ISO 27001 gaps in the audit trail - Retention and sign-in logging move from the admin panel to the "Audit" section of appsettings (AuditConfig), so a staff member can no longer shorten the trail or switch sign-in logging off. RetentionDays 0 keeps entries forever. The panel form, its model, validator, AuditSettings and their resources are gone. - A staff member refused an admin page or action for lack of a permission is recorded (AccessDenied), as ISO 27001 A.8.15 expects of failed access. - Installing, uninstalling, removing and uploading a plugin is recorded: these change the running code but write no database document. --- .../Grand.Data/Audit/AuditRepositoryAccess.cs | 12 --- src/Core/Grand.Data/Audit/IAuditTracker.cs | 8 +- src/Core/Grand.Data/Audit/RepositoryAudit.cs | 21 +++-- .../LiteDb/AuditingLiteDBRepository.cs | 8 +- .../Mongo/AuditingMongoRepository.cs | 8 +- src/Core/Grand.Domain/Audit/AuditKind.cs | 4 +- src/Core/Grand.Domain/Audit/AuditSettings.cs | 16 ---- .../Audit/AuditTracker.cs | 12 ++- .../Configuration/AuditConfig.cs | 19 +++++ src/Core/Grand.Infrastructure/StartupBase.cs | 1 + .../Services/InstallDataSettings.cs | 2 - .../AuditLogRetentionScheduleTask.cs | 10 ++- .../Audit/AuditTrackerTests.cs | 9 ++- .../AuditLogRetentionScheduleTaskTests.cs | 13 ++-- .../Controllers/AuditLogControllerTests.cs | 15 +--- .../Audit/AuditEventRecorderTests.cs | 32 +++++++- .../PermissionAuthorizeAttributeTests.cs | 72 ++++++++++++++++++ .../Grand.Web.Admin/App_Data/appsettings.json | 9 +++ .../Areas/Admin/Views/AuditLog/List.cshtml | 14 ---- .../Controllers/AuditLogController.cs | 28 +------ .../Controllers/PluginController.cs | 12 ++- .../Models/AuditLog/AuditLogListModel.cs | 1 - .../Models/AuditLog/AuditSettingsModel.cs | 13 ---- .../AuditLog/AuditSettingsValidator.cs | 17 ----- .../Audit/AuditEventRecorder.cs | 23 +++++- .../Audit/IAuditEventRecorder.cs | 10 +++ .../PermissionAuthorizeActionAttribute.cs | 3 + .../PermissionAuthorizeAttribute.cs | 12 ++- .../Grand.Web.Store/App_Data/appsettings.json | 9 +++ .../App_Data/appsettings.json | 9 +++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1630746 -> 1629352 bytes .../App_Data/Resources/Upgrade/en_250.xml | 24 ++---- src/Web/Grand.Web/App_Data/appsettings.json | 9 +++ 33 files changed, 268 insertions(+), 187 deletions(-) delete mode 100644 src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs delete mode 100644 src/Core/Grand.Domain/Audit/AuditSettings.cs create mode 100644 src/Core/Grand.Infrastructure/Configuration/AuditConfig.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/Security/Authorization/PermissionAuthorizeAttributeTests.cs delete mode 100644 src/Web/Grand.Web.Admin/Models/AuditLog/AuditSettingsModel.cs delete mode 100644 src/Web/Grand.Web.Admin/Validators/AuditLog/AuditSettingsValidator.cs diff --git a/src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs b/src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs deleted file mode 100644 index 945b0a9228..0000000000 --- a/src/Core/Grand.Data/Audit/AuditRepositoryAccess.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Grand.Domain; - -namespace Grand.Data.Audit; - -/// -/// How the tracker reads documents of one type back, supplied by the repository -/// -public sealed class AuditRepositoryAccess(Func, Task>> loadMany) - where T : BaseEntity -{ - public Func, Task>> LoadMany { get; } = loadMany; -} diff --git a/src/Core/Grand.Data/Audit/IAuditTracker.cs b/src/Core/Grand.Data/Audit/IAuditTracker.cs index d397c0264e..edd47da671 100644 --- a/src/Core/Grand.Data/Audit/IAuditTracker.cs +++ b/src/Core/Grand.Data/Audit/IAuditTracker.cs @@ -16,13 +16,15 @@ public interface IAuditTracker /// /// Called before a write of a single document (after it, for an insert). The first call for an id - /// snapshots the stored document; null means every field. + /// snapshots the stored document with , which also reads the "after" state at the + /// flush; null means every field. /// - Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryAccess access, + 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 the flush does not re-read it + /// 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; diff --git a/src/Core/Grand.Data/Audit/RepositoryAudit.cs b/src/Core/Grand.Data/Audit/RepositoryAudit.cs index c5d4f28f9d..7424f40ddd 100644 --- a/src/Core/Grand.Data/Audit/RepositoryAudit.cs +++ b/src/Core/Grand.Data/Audit/RepositoryAudit.cs @@ -6,18 +6,22 @@ namespace Grand.Data.Audit; /// /// The audit hooks shared by the Mongo and LiteDB auditing repositories /// -public sealed class RepositoryAudit(IAuditTracker tracker, AuditRepositoryAccess access) where T : BaseEntity +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, access) : Task.CompletedTask; + return Tracking ? tracker.BeforeWriteAsync(entity.Id, true, loadMany) : Task.CompletedTask; } - public Task BeforeFullUpdate(T entity) + /// + /// A full update or a delete of one document + /// + public Task BeforeWrite(T entity) { - return Tracking ? tracker.BeforeWriteAsync(entity.Id, false, access) : Task.CompletedTask; + return Tracking ? tracker.BeforeWriteAsync(entity.Id, false, loadMany) : Task.CompletedTask; } public void AfterFullUpdate(T entity) @@ -26,11 +30,6 @@ public void AfterFullUpdate(T entity) tracker.AfterFullUpdate(entity); } - public Task BeforeDelete(T entity) - { - return Tracking ? tracker.BeforeWriteAsync(entity.Id, false, access) : Task.CompletedTask; - } - /// /// A write of one field (or one collection) of one document; an empty id means every document /// @@ -45,7 +44,7 @@ public Task BeforeField(string id, LambdaExpression field, string operation) return Task.CompletedTask; } - return tracker.BeforeWriteAsync(id, false, access, Paths([AuditFilterId.MemberPath(field)])); + return tracker.BeforeWriteAsync(id, false, loadMany, Paths([AuditFilterId.MemberPath(field)])); } public Task BeforeUpdateOne(Expression> filter, UpdateBuilder builder) @@ -59,7 +58,7 @@ public Task BeforeUpdateOne(Expression> filter, UpdateBuilder b return Task.CompletedTask; } - return tracker.BeforeWriteAsync(id, false, access, Paths(AuditFilterId.MemberPaths(builder))); + return tracker.BeforeWriteAsync(id, false, loadMany, Paths(AuditFilterId.MemberPaths(builder))); } public void Bulk(string operation, string filter) diff --git a/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs b/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs index 59d7dca462..5586c2aa2f 100644 --- a/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs +++ b/src/Core/Grand.Data/LiteDb/AuditingLiteDBRepository.cs @@ -15,14 +15,14 @@ public class AuditingLiteDBRepository : LiteDBRepository where T : BaseEnt public AuditingLiteDBRepository(LiteDatabase database, IAuditInfoProvider auditInfoProvider, IAuditTracker tracker) : base(database, auditInfoProvider) { - _audit = new RepositoryAudit(tracker, new AuditRepositoryAccess(LoadMany)); + _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, new AuditRepositoryAccess(LoadMany)); + _audit = new RepositoryAudit(tracker, LoadMany); } //by id, one at a time: Table would read the whole collection @@ -41,7 +41,7 @@ public override async Task InsertAsync(T entity) public override async Task UpdateAsync(T entity) { - await _audit.BeforeFullUpdate(entity); + await _audit.BeforeWrite(entity); var updated = await base.UpdateAsync(entity); _audit.AfterFullUpdate(entity); return updated; @@ -95,7 +95,7 @@ public override async Task RemoveCollectionFieldItem(string id, Expression DeleteAsync(T entity) { - await _audit.BeforeDelete(entity); + await _audit.BeforeWrite(entity); return await base.DeleteAsync(entity); } diff --git a/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs b/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs index a47782dc59..547988f427 100644 --- a/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs +++ b/src/Core/Grand.Data/Mongo/AuditingMongoRepository.cs @@ -16,14 +16,14 @@ public class AuditingMongoRepository : MongoRepository where T : BaseEntit public AuditingMongoRepository(IMongoDatabase database, IAuditInfoProvider auditInfoProvider, IAuditTracker tracker) : base(database, auditInfoProvider) { - _audit = new RepositoryAudit(tracker, new AuditRepositoryAccess(LoadMany)); + _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, new AuditRepositoryAccess(LoadMany)); + _audit = new RepositoryAudit(tracker, LoadMany); } private async Task> LoadMany(IReadOnlyCollection ids) @@ -40,7 +40,7 @@ public override async Task InsertAsync(T entity) public override async Task UpdateAsync(T entity) { - await _audit.BeforeFullUpdate(entity); + await _audit.BeforeWrite(entity); var updated = await base.UpdateAsync(entity); _audit.AfterFullUpdate(entity); return updated; @@ -94,7 +94,7 @@ public override async Task RemoveCollectionFieldItem(string id, Expression DeleteAsync(T entity) { - await _audit.BeforeDelete(entity); + await _audit.BeforeWrite(entity); return await base.DeleteAsync(entity); } diff --git a/src/Core/Grand.Domain/Audit/AuditKind.cs b/src/Core/Grand.Domain/Audit/AuditKind.cs index 2ca9c706bb..fe438bef1c 100644 --- a/src/Core/Grand.Domain/Audit/AuditKind.cs +++ b/src/Core/Grand.Domain/Audit/AuditKind.cs @@ -12,9 +12,11 @@ public enum AuditKind SignIn = 10, SignInFailed = 11, SignOut = 12, + AccessDenied = 13, Export = 20, ImpersonationStart = 30, ImpersonationEnd = 31, AuditLogViewed = 40, - AuditLogExported = 41 + AuditLogExported = 41, + Plugin = 50 } diff --git a/src/Core/Grand.Domain/Audit/AuditSettings.cs b/src/Core/Grand.Domain/Audit/AuditSettings.cs deleted file mode 100644 index 66308fda57..0000000000 --- a/src/Core/Grand.Domain/Audit/AuditSettings.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Grand.Domain.Configuration; - -namespace Grand.Domain.Audit; - -public class AuditSettings : ISettings -{ - /// - /// Gets or sets the number of days audit entries are kept - /// - public int RetentionDays { get; set; } = 365; - - /// - /// Gets or sets a value indicating whether staff sign-ins are recorded - /// - public bool LogStaffSignIns { get; set; } = true; -} diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 66ac98457d..dd416d7e2e 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -50,8 +50,9 @@ public bool IsAudited(Type entityType) return !ExcludedTypes.Contains(entityType.Name); } - public async Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryAccess access, - IReadOnlyCollection writtenPaths = null) where T : BaseEntity + public async Task BeforeWriteAsync(string id, bool isInsert, + Func, Task>> loadMany, IReadOnlyCollection writtenPaths = null) + where T : BaseEntity { if (!IsActive || string.IsNullOrEmpty(id)) return; @@ -78,7 +79,7 @@ public async Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryA if (!isInsert) try { - var stored = (await access.LoadMany([id])).FirstOrDefault(); + var stored = (await loadMany([id])).FirstOrDefault(); before = stored == null ? null : AuditSnapshot.Take(stored); } catch (Exception ex) @@ -91,7 +92,7 @@ public async Task BeforeWriteAsync(string id, bool isInsert, AuditRepositoryA lock (_lock) { _loaders[typeof(T)] = async ids => - (await access.LoadMany(ids)).ToDictionary(e => e.Id, e => AuditSnapshot.Take(e)); + (await loadMany(ids)).ToDictionary(e => e.Id, e => AuditSnapshot.Take(e)); if (_pending.TryGetValue(key, out var existing)) existing.Merge(writtenPaths); @@ -287,6 +288,9 @@ private sealed class Pending(IDictionary before, IReadOnlyCollec /// 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) 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/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.Installer/Services/InstallDataSettings.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs index d1a2a7ac04..f232ca2ac7 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs @@ -1,5 +1,4 @@ using Grand.Domain.Admin; -using Grand.Domain.Audit; using Grand.Domain.Blogs; using Grand.Domain.Catalog; using Grand.Domain.Cms; @@ -64,7 +63,6 @@ await _settingRepository.SaveSetting(new CommonSettings { await _settingRepository.SaveSetting(new SystemSettings { DeleteGuestTaskOlderThanMinutes = 1440 }); - await _settingRepository.SaveSetting(new AuditSettings()); await _settingRepository.SaveSetting(new SecuritySettings { AdminAreaAllowedIpAddresses = null }); diff --git a/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs index a6c3b3e308..a908bdd01d 100644 --- a/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs +++ b/src/Modules/Grand.Module.ScheduledTasks/BackgroundServices/AuditLogRetentionScheduleTask.cs @@ -1,18 +1,20 @@ using Grand.Business.Core.Interfaces.Common.Audit; using Grand.Business.Core.Interfaces.System.ScheduleTasks; -using Grand.Domain.Audit; +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, AuditSettings auditSettings) : IScheduleTask +public class AuditLogRetentionScheduleTask(IAuditService auditService, AuditConfig auditConfig) : IScheduleTask { public async Task Execute(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var days = Math.Max(1, auditSettings.RetentionDays); - await auditService.DeleteOlderThan(DateTime.UtcNow.AddDays(-days)); + if (auditConfig.RetentionDays <= 0) + return; + + await auditService.DeleteOlderThan(DateTime.UtcNow.AddDays(-auditConfig.RetentionDays)); } } diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs index 4eeffe6800..717c931352 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs @@ -14,7 +14,7 @@ public class AuditTrackerTests { private readonly Dictionary _store = new(); private int _loadCalls; - private AuditRepositoryAccess _access; + private Func, Task>> _access; private Mock _mediator; private CapturingWriter _writer; private AuditTracker _tracker; @@ -28,12 +28,12 @@ public void Init() _writer = new CapturingWriter(); _mediator = new Mock(); _tracker = new AuditTracker(_writer, _mediator.Object, NullLogger.Instance); - _access = new AuditRepositoryAccess(ids => + _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) @@ -197,7 +197,8 @@ public async Task BeforeWrite_OnlyActivityFields_NoReadNoEntry() public async Task BeforeRead_Throws_DoesNotFailTheWrite() { _tracker.Activate(Actor); - var failing = new AuditRepositoryAccess(_ => throw new InvalidOperationException("db down")); + Func, Task>> failing = + _ => throw new InvalidOperationException("db down"); await _tracker.BeforeWriteAsync("1", false, failing, ["Price"]); await _tracker.FlushAsync(); diff --git a/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs index acb17714b6..8b4f566224 100644 --- a/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs +++ b/src/Tests/Grand.Modules.Tests/Services/BackgroundService/AuditLogRetentionScheduleTaskTests.cs @@ -1,5 +1,5 @@ using Grand.Business.Core.Interfaces.Common.Audit; -using Grand.Domain.Audit; +using Grand.Infrastructure.Configuration; using Grand.Module.ScheduledTasks.BackgroundServices; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -24,7 +24,7 @@ public void Init() [TestMethod] public async Task Execute_DeletesOlderThanRetention() { - var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditSettings { RetentionDays = 30 }); + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditConfig { RetentionDays = 30 }); await task.Execute(CancellationToken.None); @@ -35,7 +35,7 @@ public async Task Execute_DeletesOlderThanRetention() [TestMethod] public async Task Execute_Cancelled_DeletesNothing() { - var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditSettings()); + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditConfig()); await Assert.ThrowsExactlyAsync(() => task.Execute(new CancellationToken(true))); @@ -43,13 +43,12 @@ public async Task Execute_Cancelled_DeletesNothing() } [TestMethod] - public async Task Execute_RetentionZero_TreatedAsOneDay() + public async Task Execute_RetentionZero_KeepsEverything() { - var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditSettings { RetentionDays = 0 }); + var task = new AuditLogRetentionScheduleTask(_auditService.Object, new AuditConfig { RetentionDays = 0 }); await task.Execute(CancellationToken.None); - var expected = DateTime.UtcNow.AddDays(-1); - Assert.IsTrue(Math.Abs((_cutoff - expected).TotalSeconds) < 5, _cutoff.ToString("o")); + _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 index 22813e6bf5..e09c063490 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs @@ -1,5 +1,4 @@ using Grand.Business.Core.Interfaces.Common.Audit; -using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Domain; using Grand.Domain.Audit; @@ -8,7 +7,6 @@ using Grand.Infrastructure.Validators; using Grand.Web.Admin.Controllers; using Grand.Web.Admin.Models.AuditLog; -using Grand.Web.Admin.Validators.AuditLog; using Grand.Web.Common.Audit; using Grand.Web.Common.DataSource; using Grand.Web.Common.Localization; @@ -40,7 +38,7 @@ public void Setup() var translation = new Mock(); translation.Setup(t => t.GetResource(It.IsAny())).Returns(key => key); _controller = new AuditLogController(_auditService.Object, _recorder.Object, - new Mock().Object, new AuditSettings(), storeService.Object, translation.Object); + storeService.Object, translation.Object); _httpContext = new DefaultHttpContext(); _httpContext.Response.Body = new MemoryStream(); _controller.ControllerContext = new ControllerContext { HttpContext = _httpContext }; @@ -212,17 +210,6 @@ public void Controller_HasPermissionAuthorizeAuditLog() Assert.AreEqual(PermissionSystemName.AuditLog, attribute.Permission); } - [TestMethod] - public async Task SettingsValidator_RetentionZero_Invalid() - { - var translation = new Mock(); - translation.Setup(t => t.GetResource(It.IsAny())).Returns("message"); - var validator = new AuditSettingsValidator([], translation.Object); - - Assert.IsFalse((await validator.ValidateAsync(new AuditSettingsModel { RetentionDays = 0 })).IsValid); - Assert.IsTrue((await validator.ValidateAsync(new AuditSettingsModel { RetentionDays = 1 })).IsValid); - } - [TestMethod] public void HistoryPopup_PassesTypeAndId() { diff --git a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs index f5a0676835..6c9219c4df 100644 --- a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs @@ -5,6 +5,7 @@ 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; @@ -23,7 +24,7 @@ public class AuditEventRecorderTests private Mock _auditService; private Mock _groupService; private Mock _workContext; - private AuditSettings _settings; + private AuditConfig _config; private DefaultHttpContext _httpContext; private List _recorded; private AuditEventRecorder _recorder; @@ -42,14 +43,14 @@ public void Init() _workContext.SetupGet(w => w.CurrentCustomer).Returns(Staff); var contextAccessor = new Mock(); contextAccessor.SetupGet(c => c.WorkContext).Returns(_workContext.Object); - _settings = new AuditSettings(); + _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, _settings, _groupService.Object, + _recorder = new AuditEventRecorder(_auditService.Object, _config, _groupService.Object, new HttpContextAccessor { HttpContext = _httpContext }, contextAccessor.Object, NullLogger.Instance); } @@ -82,6 +83,29 @@ public async Task SignIn_Storefront_SourceStorefront() 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() { @@ -108,7 +132,7 @@ public async Task SignIn_Customer_NotRecorded() [TestMethod] public async Task SignIn_SettingOff_NotRecorded() { - _settings.LogStaffSignIns = false; + _config.LogStaffSignIns = false; await _recorder.SignIn(Staff); await _recorder.SignInFailed(Staff, "wrong password"); 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/Web/Grand.Web.Admin/App_Data/appsettings.json b/src/Web/Grand.Web.Admin/App_Data/appsettings.json index d80ccdc7d3..1d4ce0a7ed 100644 --- a/src/Web/Grand.Web.Admin/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Admin/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.Admin/Areas/Admin/Views/AuditLog/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml index 7dc3ca1d60..d83f3bb7f4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml @@ -47,20 +47,6 @@ - -
- - - - -
- - -
-
-