Skip to content

Record staff changes in an admin audit trail - #996

Merged
KrzysztofPajak merged 7 commits into
developfrom
feature/audit-log
Oct 29, 2026
Merged

KrzysztofPajak merged 7 commits into
developfrom
feature/audit-log

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

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 HistoryObject stores whole snapshots without an actor for newsletter subscriptions only.

Solution

A staff audit trail, built into core.

What is recorded

  • Every write made by staff, from these sources:
    • the Admin, Store and Vendor panels;
    • the API and SCIM;
    • the storefront while a staff member impersonates a customer.
  • Each entry holds: actor, time (UTC), source, IP, user agent, request action (Admin / Order / CancelOrder), operation id, entity type/id/name, the store the record applies to, and the changed fields with before and after values.
  • Staff sign-ins, failed sign-ins and sign-outs (can be switched off; switching it off is itself recorded).
  • Exports: 19 admin export actions carry [AuditExport].
  • Impersonation start and end, and opening or exporting the audit log itself.

How it is captured

  • AuditingMongoRepository<T> and AuditingLiteDBRepository<T> are subclasses of the existing repositories, so IRepository<T> is unchanged and is MongoRepository<T> checks keep working. They report every write to a scoped IAuditTracker.
  • The tracker is inactive until the global AuditScopeFilter activates it for a staff request. Storefront traffic and scheduled tasks pay one boolean check.
  • Each entity is read once before its first write. The "after" state is read in batches of 200 and at request end, and entries are written with one batch insert.
  • AuditDiff compares 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.
  • Arrays are matched by element id, or by key for user fields. Settings JSON is diffed field by field.
  • Passwords, salts, tokens, API keys and other secrets are masked, including those inside user fields and settings.
  • A failing audit read, diff or write is logged and never fails the user's request.

Admin (Main Admin only, ManageAuditLog permission)

  • System > Audit log: filters, entry detail, CSV export (RFC 4180, formula-safe), and the retention and sign-in settings.
  • 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.
  • Store and Vendor panels get no screen, no button and no menu entry.

Immutability and retention

  • There is no edit or delete path. The daily "Audit log retention" task (365 days by default) is the only way entries leave the log.
  • An AuditEntryCreated notification lets a plugin forward entries, e.g. to a SIEM.

Install and upgrade

  • The installer creates the indexes, the retention task, the permission, the menu entry and the resources.
  • Migration 2.5 MigrationAuditLog adds the same to existing installations.

Breaking changes

  • Actions gained a [FromServices] IAuditEventRecorder parameter:

    • BaseHomeController.Logout (Admin/Store/Vendor);
    • BaseCustomerManagementController.Impersonate;
    • storefront AccountController.Logout.

    Code that calls these actions directly must pass the recorder. Routes and form posts are unchanged.

  • IRepository<> now resolves to AuditingMongoRepository<> / AuditingLiteDBRepository<>. MongoRepository<T>.ClearAsync and LiteDBRepository<T>.ClearAsync are now virtual.

  • Staff writes cost one extra read per touched entity plus one batch read and insert per request. Storefront requests are unaffected.

Testing

  1. Install a fresh store (MongoDB, sample data). Sign in as the admin. Sign out, then sign in once with a wrong password and once correctly.
  2. Open Admin > System > Audit log. Expected: "Sign-in failed" and "Sign-in" for the admin, plus "Audit log viewed".
  3. Open a few admin screens without saving anything, then reload the audit log. Expected: no new Update entries, only another "Audit log viewed".
  4. Edit a product: change Price and put <b>bold</b> {{ 7*7 }} into the full description. Save.
    • The audit log shows one Update for the product with Price: old → new.
    • The product edit page has a "Change history" button. Its popup lists the change.
    • The entry detail shows <b>bold</b> and {{ 7*7 }} as plain text.
  5. Admin > Configuration > Widgets: deactivate and reactivate a widget.
    • Expected: two Setting widgetsettings (All stores) entries with Metadata.ActiveWidgetSystemNames changes.
    • The "Change history" button on Settings > Catalog does not list them.
  6. Customers > edit a customer > Impersonate. Browse the storefront, then sign out.
    • Expected: "Impersonation started" (Admin) and "Impersonation ended" (Storefront), both with the admin as actor.
    • Expected: UserFields[ImpersonatedCustomerId] updates on the admin's customer.
  7. Export countries (Configuration > Countries > Export to Excel), then Audit log > Export CSV.
    • Expected: an "Export" entry for Country and an "Audit log exported" entry.
    • The CSV opens in a spreadsheet; a value starting with = is prefixed with '.
  8. Sign in to the Store panel as a store owner. Expected: no Audit log menu and no "Change history" buttons; /Admin/AuditLog/List is denied.
  9. Upgrade an existing 2.4 database. Expected: migration "Audit log: indexes, retention task, permission and admin menu" runs, and Administrators see the menu entry.

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.

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.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 20:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

{
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);
Comment thread src/Core/Grand.Infrastructure/Audit/AuditDiff.cs Fixed
private Mock<IGroupService> _groupServiceMock;
private Mock<IRequestPermissionService> _permissionServiceMock;
private Mock<IContextAccessor> _contextAccessorMock;
private Mock<IAuditEventRecorder> _auditEventRecorderMock = new();
Comment thread src/Core/Grand.Infrastructure/Audit/AuditDiff.cs Fixed
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;
}
Comment thread src/Core/Grand.Infrastructure/Audit/AuditTracker.cs Fixed
Comment thread src/Core/Grand.Infrastructure/Audit/AuditTracker.cs Fixed
support 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.
Comment thread src/Core/Grand.Infrastructure/Audit/AuditDiff.cs Fixed
Comment thread src/Core/Grand.Infrastructure/Audit/AuditDiff.cs Fixed
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;
}
Comment thread src/Core/Grand.Infrastructure/Audit/AuditTracker.cs Fixed
support 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);
}
@KrzysztofPajak
KrzysztofPajak merged commit 52b6dcc into develop Oct 29, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/audit-log branch October 29, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants