Search by

magenxcommerce / module-admin-activity

magenxcommerce

Admin action audit log for Magento 2 — records admin add/edit/delete/view/print/mass-update operations with field-level before/after values, login and logout activity, and the IP address and user agent behind each action.

Package info

github.com/magenxcommerce/module-admin-activity

Type:magento2-module

pkg:composer/magenxcommerce/module-admin-activity

Statistics

Installs: 0

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

v1.2.0 2026-10-20 08:29 UTC

This package is auto-updated.

Last update: 2026-10-24 19:25:32 UTC


README

Built with AI assistance. This module is built with significant use of Claude Code, under active human review and guidance. It is open source and in active development. AI assistance does not lower the engineering bar: every change must meet the same correctness, security, documentation and maintainability requirements as any other contribution.

No warranty. We are not responsible for any incompatibility. By installing this software, you accept responsibility for its security, upgrades, patches and monitoring.

Admin action audit log for Magento 2 Open Source. Records what an admin changed, field by field, along with who did it, when, from which IP address, and with which browser.

Magento Open Source ships no admin action log — that feature is Adobe Commerce's Magento_Logging. This module fills the gap: an admin panel that several people share is otherwise a system where "who deleted that CMS block" has no answer.

What is recorded

Entity changes add, edit, delete, print, view and mass update, per tracked entity
Field-level detail the old and new value of every field that actually moved
Login activity successful logins, failed attempts (with the username tried) and logouts
Request context IP address, user agent, admin username, admin role, controller action, URL
External changes changes made through the AI MCP server (or any caller using ExternalActorScope), with the connector and role behind them
Page visits one row per admin page view — off by default, see Performance

Admin

System → Admin Activity Log. A filterable, exportable grid; each row opens a detail page showing the request context and a field / old value / new value table.

The grid is read-only. There is no delete and no mass delete, by design — an audit log an admin can rewrite from the UI is not an audit log. Records leave only through the retention cron.

Settings: Stores → Configuration → Magenx → Admin Activity.

Configuration

Path Default Notes
magenx_admin_activity/general/enabled 1 Master switch. Off records nothing; existing rows are kept
magenx_admin_activity/general/log_login 1 Logins, failed attempts, logouts
magenx_admin_activity/general/log_page_visits 0 One row per admin page view — see Performance
magenx_admin_activity/general/cleanup_days 90 Retention window. 0 keeps everything forever
magenx_admin_activity/general/cleanup_schedule 0 3 * * * Cron expression, retimable without code

Every field is global (showInDefault only): the admin panel is not a store view, so a per-website audit setting would be a control that looks like it does something and does not.

Extending it

Everything extensible is data in etc/di.xml. A project adds its own entities and rules from its own di.xml; no class needs subclassing.

Track another entity — one <item> in the EntityRegistry entities argument:

<type name="Magenx\AdminActivity\Model\Activity\EntityRegistry">
    <arguments>
        <argument name="entities" xsi:type="array">
            <item name="helpdesk_ticket" xsi:type="array">
                <item name="class" xsi:type="string">Magenx\Helpdesk\Model\Ticket</item>
                <item name="label" xsi:type="string">Support Ticket</item>
                <item name="name_field" xsi:type="string">subject</item>
            </item>
        </argument>
    </arguments>
</type>

name_field is optional — omitted, the entity name falls back to name / title / sku / code / label / email / username / increment_id, in that order. Subclasses and the generated \Interceptor classes resolve to their configured ancestor, so listing the base class is enough.

Out of the box: products, categories, product attributes, CMS pages and blocks, customers, customer groups, orders, admin users and roles, stores, store groups, websites, cart and catalog price rules, tax rules, and store configuration values.

Protect a field — add to FieldFilter's protectedFields (exact name) or protectedPatterns (case-insensitive substring). Drop a field entirely — add to skipFields.

Exclude a route from page-visit logging — add an action-name prefix to FlushActivity's ignoredActions.

Security

Model allowlist. entity_type is a class name stored in a database column. EntityRegistry::getAllowedClass() returns a class only if it appears verbatim in the configured entity map — not even a subclass passes — and nothing in the admin UI resolves that column any other way. A tampered or restored row cannot turn a page render into arbitrary class instantiation.

Protected fields. Passwords, reset tokens, API keys, secrets, private keys and card data are never stored. A protected field keeps its name in the log and both values become ***: an audit trail has to show that a credential changed without recording what it changed to.

Magento\Config\Model\Config\Backend\Encrypted is masked at the class level rather than the field level, because its sensitive data sits in a column called value that no field-name rule would flag — that column holds the ciphertext of a payment gateway key or an SMTP password.

Config paths. The same protection covers the config backends that are not Encrypted. Every store-config save arrives as a Magento\Framework\App\Config\Value whose secret, if it has one, sits in that same value column — and plenty of third-party modules store an SMTP password or a webhook secret under a plain Value backend with no encryption at all. The config path (payment/acme/api_key) is matched against the same protectedPatterns, so those values are masked on the way in.

Admin secret key. The /key/<hash>/ segment is stripped from the stored URL. It is a live per-session CSRF token and has no business in a table that a reporting user or a database export can read. The query string is filtered through the same rules, so a route taking ?token=… or ?api_key=… stores *** rather than the value — request_url is one of the columns the grid exports to CSV.

Failed logins. The attempted username is stored; nothing the attempt submitted is. Only Magento's own (deliberately generic) exception message is kept, so guessed passwords never reach the database.

ACL. Magenx_AdminActivity::activity gates the grid, the detail page and the grid's data source. Magenx_AdminActivity::config gates the settings.

CSRF. Both controllers are HttpGetActionInterface and neither writes: there is no state-changing route in this module to protect. The grid's own AJAX is core's mui/index/render, which enforces the form key itself.

Performance

Page visit logging is off by default. It writes one row for every admin page view, which on a busy install is by far the largest source of rows in the log. Grid AJAX (mui/index/render), the login screens, notification polling and this module's own screens are excluded even when it is on, as is every XHR.

One write per request. Entity changes are buffered during the request and written once at controller_action_postdispatch. Field detail rows go in with a single insertMultiple per entity.

Mass actions are capped. Past 200 entities in one request (maxEntitiesPerRequest in di.xml) the log writes a single summary row naming the record count instead of one row per entity, so a "select all" mass action cannot turn into a five-figure insert.

Values are truncated at 128 KB, on a byte budget that never splits a multi-byte character. Hence mediumtext value columns — the ceiling is above text's 64 KB limit.

Indexes cover the queries the grid and the cron actually run: created_at, user_id, (action_type, created_at), (entity_type, entity_id) and ip_address.

The IP filter is an exact match, not a substring — a grid text filter is LIKE '%…%' by default, which no index can serve, and "everything from this address" is the query that index exists for. Type the whole address. Every other text column still filters on substrings.

Writes are one transaction. A request's rows go in together or not at all, so a failure part-way through a mass action cannot leave some entities recorded and others silently missing. Detail rows are inserted in chunks, because a wide save of 128 KB values in a single statement can outgrow max_allowed_packet.

Cleanup is batched. The cron deletes at most 5000 parent rows per statement and lets the CASCADE foreign key remove the detail rows, so an install where cron has not run for months does not attempt one unbounded DELETE.

Logging never breaks the action it is auditing. Every database write is wrapped; a failure goes to var/log/magenx_admin_activity.log and the admin's own request continues.

How it hooks in

Observers are registered in etc/adminhtml/events.xml, never in a global etc/events.xml — the model events fire in every area, and area-scoped registration is what keeps storefront and cron traffic out of the log without an App\State lookup on the hot path.

The commit variants are used deliberately. model_save_after fires inside the still-open transaction, so a save rolled back afterwards would leave a log entry for a change that never landed; the commit callbacks run only after a successful commit, and both AbstractDb::save() and the EAV AbstractEntity::save() register them, so products and customers are covered alongside flat-table models.

Event Purpose
model_save_commit_after diff getOrigData() against getData() — the snapshot is still intact here, because AbstractDb::save() never calls setOrigData()
model_delete_commit_after record the entity's final state
controller_action_postdispatch write the buffer; or one page-visit row
backend_auth_user_login_success login
backend_auth_user_login_failed failed attempt
controller_action_predispatch_adminhtml_auth_logout logout — Auth\Session::processLogout() dispatches nothing and destroys the session, so by postdispatch there is no user left to name

Changes from outside the admin panel

An integration token or an OAuth connection changes the same entities an admin does, but through a frontend controller with no admin session, so the observers above never see it. A caller that acts on someone's behalf opens a scope instead:

$actor = new ExternalActor(
    $adminUserId,            // null for an integration
    'alice',                 // or 'integration:Reporting'
    'mcp',                   // stored in `source`
    'Claude',                // stored in `connector`: integration or OAuth client
    'Catalog Managers',      // stored in `role_name`
    'mcp/update_product'     // stored in `full_action_name`
);
$result = $externalActorScope->run($actor, fn () => $this->doTheWrite());

While the callable runs, Observer\LogExternalChange - registered in etc/frontend/events.xml on the same two commit events - buffers each tracked entity that commits. When the callable returns, or throws after committing something, the entries are written at once under the actor. Outside a scope the frontend observer returns after one property read, so storefront saves are never logged and never pay for a session lookup.

Every row carries source (admin for the panel, otherwise the caller, e.g. mcp), connector and role_name. Admin-panel rows get role_name from the signed-in admin's role, so the log shows which role authorised a change even after the role is edited or reassigned. The grid has a column and a filter for each.

Login is taken from backend_auth_user_login_success rather than admin_user_authenticate_after, because the latter also fires from User::performIdentityCheck() — the re-authentication prompt in front of sensitive screens — and would log a second "login" for what is one session.

Requirements

  • Magento Open Source / Adobe Commerce 2.4.8+
  • PHP 8.3, 8.4 or 8.5

Installation

composer require magenxcommerce/module-admin-activity
bin/magento module:enable Magenx_AdminActivity
bin/magento setup:upgrade
bin/magento cache:flush

Tests

vendor/bin/phpunit -c vendor/magenxcommerce/module-admin-activity/phpunit.xml.dist

From a checkout of this repository, with no Magento install and no Composer install — the tested classes import nothing from magento/framework, and Test/Unit/bootstrap.php autoloads them directly:

curl -fsSLO https://phar.phpunit.de/phpunit-10.5.phar
php phpunit-10.5.phar -c phpunit.xml.dist

CI runs the same suite on every pull request (the tests job in .github/workflows/ci.yml).

The suite covers the classes that decide what gets logged and how values are rendered — where a mistake either loses an audit record or writes a secret into the database. Those classes import nothing from magento/framework, so the suite also runs standalone against a bare PHPUnit; Test/Unit/bootstrap.php maps the namespace either way.

Note that magenxcommerce/workflows' php-module-ci.yml runs php -l across PHP 8.3/8.4/8.5, composer validate, PHPCS Magento2 and XML/JSON well-formedness — but no PHPUnit job. The suite is a local and pre-push check.

License

MIT