magenxcommerce / module-admin-activity
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
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- magento/framework: ^103.0
- magento/module-backend: *
- magento/module-config: *
- magento/module-cron: *
- magento/module-store: *
- magento/module-ui: *
- magento/module-user: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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