Repository navigation
Record staff changes in an admin audit trail - #996
Merged
Merged
Conversation
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<T>` checks keep working and IRepository<T> 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.
| { | ||
| var query = repository.Table; | ||
| if (search.FromUtc.HasValue) | ||
| query = query.Where(x => x.CreatedOnUtc >= search.FromUtc.Value); |
| 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 (search.Source.HasValue) | ||
| query = query.Where(x => x.Source == search.Source.Value); |
| 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); |
| private Mock<IGroupService> _groupServiceMock; | ||
| private Mock<IRequestPermissionService> _permissionServiceMock; | ||
| private Mock<IContextAccessor> _contextAccessorMock; | ||
| private Mock<IAuditEventRecorder> _auditEventRecorderMock = new(); |
Comment on lines
+80
to
+85
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Audit could not read {EntityType} {EntityId} before operation {OperationId}", | ||
| typeof(T).Name, id, _actor.OperationId); | ||
| return; | ||
| } |
added 2 commits
October 28, 2026 22:29
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.
- 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.
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.
Comment on lines
+87
to
+90
| catch (Exception) | ||
| { | ||
| return null; | ||
| } |
added 3 commits
October 29, 2026 00:07
- 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.
The label of an entry was guessed from a list of field names (Name, Title, Email, OrderNumber, Code, SystemName). An entity now marks the property that names it with [AuditName]; without the mark, Name is used. The entities that have no Name are marked (Customer.Email, Order.OrderNumber, the Title of pages, posts, news and reviews, and a few more), and the label is resolved by the tracker, which knows the entity type, so the diff only compares.
Two hooks could still throw into the repository: the snapshot taken after a full update, which would report an error for a change already stored, and the evaluation of the id in an UpdateOneAsync filter, where a throwing getter would stop the write. Both are now caught; the snapshot failure is logged, and a filter whose id cannot be read is recorded as a bulk write.
|
|
||
| public class UnserializableThing : BaseEntity | ||
| { | ||
| public string Broken { get => throw new InvalidOperationException("broken getter"); set { } } |
Comment on lines
+104
to
+108
| catch (Exception) | ||
| { | ||
| //a getter that throws: the write is recorded as a bulk write instead of failing | ||
| return false; | ||
| } |
Comment on lines
+131
to
+135
| catch (Exception ex) | ||
| { | ||
| logger.LogError(ex, "Audit could not keep {EntityType} {EntityId} after operation {OperationId}", | ||
| typeof(T).Name, entity.Id, _actor.OperationId); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves: no issue filed (enterprise roadmap: audit trail for SOC 2 / ISO 27001)
Type: feature
Issue
Enterprise customers going through SOC 2 or ISO 27001 (A.8.15 Logging) must show who changed what in the back office, when, from where, and what the value was before and after. GrandNode keeps no such record: there is no audit log, the old activity log is gone, and
HistoryObjectstores whole snapshots without an actor for newsletter subscriptions only.Solution
A staff audit trail, built into core.
What is recorded
Admin / Order / CancelOrder), operation id, entity type/id/name, the store the record applies to, and the changed fields with before and after values.[AuditExport].How it is captured
AuditingMongoRepository<T>andAuditingLiteDBRepository<T>are subclasses of the existing repositories, soIRepository<T>is unchanged andis MongoRepository<T>checks keep working. They report every write to a scopedIAuditTracker.AuditScopeFilteractivates it for a staff request. Storefront traffic and scheduled tasks pay one boolean check.AuditDiffcompares BSON and keeps only the fields the request wrote. A customer buying a product while an admin renames it does not appear as the admin changing stock.Admin (Main Admin only,
ManageAuditLogpermission)Immutability and retention
AuditEntryCreatednotification lets a plugin forward entries, e.g. to a SIEM.Install and upgrade
MigrationAuditLogadds the same to existing installations.Breaking changes
Actions gained a
[FromServices] IAuditEventRecorderparameter:BaseHomeController.Logout(Admin/Store/Vendor);BaseCustomerManagementController.Impersonate;AccountController.Logout.Code that calls these actions directly must pass the recorder. Routes and form posts are unchanged.
IRepository<>now resolves toAuditingMongoRepository<>/AuditingLiteDBRepository<>.MongoRepository<T>.ClearAsyncandLiteDBRepository<T>.ClearAsyncare nowvirtual.Staff writes cost one extra read per touched entity plus one batch read and insert per request. Storefront requests are unaffected.
Testing
<b>bold</b> {{ 7*7 }}into the full description. Save.Price: old → new.<b>bold</b>and{{ 7*7 }}as plain text.Setting widgetsettings (All stores)entries withMetadata.ActiveWidgetSystemNameschanges.UserFields[ImpersonatedCustomerId]updates on the admin's customer.=is prefixed with'./Admin/AuditLog/Listis denied.Automated: all 23 test projects pass, about 6,000 tests in total. New tests:
Grand.Infrastructure.Tests/Audit(diff, tracker, LiteDB integration, filter ids);AuditScopeFilterTests,AuditExportAttributeTests,AuditEventRecorderTests,AuditHistoryTagHelperTests;AuditServiceTests,AuditLogControllerTests,AuditLogRetentionScheduleTaskTests,PermissionSeedTests.