ACL resources and events¶
Everything Nebula adds to the install's permission tree, event graph, cache types, database and cron schedule, in one place.
ACL resources¶
Most Nebula definitions reference a stock Magento resource directly — Magento_Catalog::products,
Magento_Customer::customer, Magento_Cms::page — because they replace stock screens and
should inherit the permissions a merchant has already set. Nebula declares its own resources
only where it adds an act that has no stock equivalent.
| Resource id | Parent | Title | What it gates |
|---|---|---|---|
Nebula_Core::config |
Magento_Config::config |
Nebula | The Nebula section in Stores → Configuration. Exists so the system.xml reference resolves against a real node; without it Magento treats the resource as missing and the section is invisible to any role that does not already inherit Magento_Config::config. |
Nebula_Roles::product_price |
Magento_Catalog::products |
Edit product price | Writing price, special_price, special_from_date, special_to_date, cost, msrp — on the admin form and through the REST API. Imports and command-line writes are not gated; see Security. |
Nebula_Roles::product_inventory |
Magento_Catalog::products |
Edit product inventory | Writing qty and is_in_stock. |
Nebula_Theme::customer_set_password |
Magento_Customer::customer |
Set A Customer's Password | Setting a customer's password directly from the admin. Separate from "manage customers" on purpose: a support role gets Send Reset Email and does not get this. |
Nebula_LandingPage::manage |
Magento_Backend::content |
Landing Pages | The landing page grid, form and save controllers. Anchored on the Content tree so roles that already grant content management pick it up without an edit. |
Nebula_Passkey::passkey_manage |
Magento_Backend::admin |
Manage Passkeys | Managing your own authenticators. Everyone who can reach the admin needs this, or they cannot set up a passkey at all. It is also the resource on the Stores › Configuration › Security › Nebula Passkey section, so a role that holds it and Magento_Config::config can edit Relying Party ID. |
Nebula_Passkey::revoke_others |
Nebula_Passkey::passkey_manage |
Revoke Other Users' Passkeys and 2FA | Taking somebody else's authenticators away. A separate privilege because it is a different act with a different risk — revoking every administrator's passkey forces the whole install back to passwords. |
Nebula_Passkey::two_step_manage |
Magento_Backend::admin |
Manage Own Two-step Verification | Managing your own second factor. A sibling of passkey_manage, not a child: the two are alternatives, and nesting would make the second factor unreachable for a role that was not given passkeys. |
Nebula_ThemeSwitcher::config |
Magento_Config::config |
Nebula Theme Switcher Configuration | The theme switcher's configuration section. |
Nebula_CrudFixture::cmsblock |
Magento_Backend::admin |
Cms Blocks | The bundled CRUD fixture module's screens. It is committed generator output and a worked example, not a merchant-facing feature. |
The two field-level resources default to withheld
product_price and product_inventory are nested under Magento_Catalog::products because
they subtract from that access rather than granting anything on their own. A role with
neither, and no product access, is unaffected. Both are withheld from every role that holds
an explicit list of resources, which is the safe direction and worth stating out loud: such
a role could change prices yesterday and cannot today until someone ticks the box. A role
granted all access keeps them.
passkey_manage opens the passkey configuration too
The same resource gates the Nebula Passkey configuration section. A role that holds both
passkey_manage and Magento_Config::config can therefore change Relying Party ID, and
changing one invalidates every passkey enrolled against that relying party. The section
carries both ids, the admin's and the storefront's, so such a role can invalidate every
administrator's passkeys, every customer's, or both. There is no way
to split the two apart, so either keep Magento_Config::config away from roles that only
need their own authenticators, or grant the pairing knowingly. See
Security.
Source: every etc/acl.xml under code/ — code/Nebula/Core, code/Nebula/Roles, code/Nebula/Theme, code/Nebula/LandingPage, code/Nebula/Passkey, code/Nebula/ThemeSwitcher. Section resources:
code/Nebula/Passkey/etc/adminhtml/system.xml.
How a definition declares its ACL¶
Every standalone grid and form definition carries a top-level acl naming the resource that
gates it:
The rule is default-deny. A definition that omits acl, or sets it to an empty string, is
denied outright. It does not fall back to a broad "any authenticated admin" resource. When
that happens a warning is logged naming the definition, so an author sees exactly why their
screen returns 403 and how to fix it.
One class, DefinitionAccessControl, turns a definition's acl into the allow/deny decision,
and every entry point routes through it — the grid and form blocks, and the data controllers'
_isAllowed() hooks. That is deliberate: a single implementation means the default-deny rule
cannot be reintroduced as an inline ?? 'Magento_Backend::admin' fallback at one call site.
Two nested places take their own resource, checked in addition to the definition's:
| Key | Where | Gates |
|---|---|---|
editable.acl |
grid definition | Inline edit and the quick-edit drawer — the write, as distinct from the read. |
acl on a button |
grid addButton, form custom buttons |
Whether that one button renders. |
Source: code/Nebula/Component/Model/Authorization/DefinitionAccessControl.php, code/Nebula/Grid/Block/Grid.php, code/Nebula/Grid/Model/InlineEdit/RowEditor.php, code/Nebula/Form/Model/Form/CustomButtonResolver.php.
Events¶
Nebula dispatches seven events. The five below drive definition resolution, form saves and grid
data, and each of those carries a single typed payload object under the array key event, so
an observer reads it as $observer->getEvent()->getData('event'). The other two are security
audit hooks, they carry plain scalars instead of an object, and they are listed under
Security events.
| Event | Payload class | Fired when | Mutable |
|---|---|---|---|
nebula_definition_resolved_before |
Nebula\Component\Model\Event\DefinitionResolveEvent |
Before any definition is resolved, for every type and id. | setDefinition() |
nebula_definition_resolved_after |
Nebula\Component\Model\Event\DefinitionResolveEvent |
After the merged definition is produced, before it reaches a grid or form. | setDefinition() |
nebula_form_save_before |
Nebula\Component\Model\Event\FormSaveEvent |
After the request is parsed, before persistence. | setData() |
nebula_form_save_after |
Nebula\Component\Model\Event\FormSaveEvent |
After persistence, before the response is built. | setSavedEntityId(), addMessage() |
nebula_grid_data_fetched_after |
Nebula\Component\Model\Event\GridDataEvent |
After a grid's data provider returns rows, before they are rendered. | setItems(), setTotalCount() |
DefinitionResolveEvent¶
| Accessor | Type | Notes |
|---|---|---|
getType() |
string |
grid, form, snippet or component. Read only. |
getId() |
string |
Definition id, matching the JSON filename stem. Read only. |
getDefinition() / setDefinition() |
array |
The definition as seen at this hook site. |
A _before observer that sets a non-empty definition supplies the answer, and resolution
is skipped entirely — that is the preload and cache hook. Empty means "no opinion", not
"resolve to nothing", because empty is the state the event starts in and an observer that
never touches it must not blank the definition.
public function execute(Observer $observer): void
{
/** @var \Nebula\Component\Model\Event\DefinitionResolveEvent $event */
$event = $observer->getEvent()->getData('event');
if ($event->getType() !== 'grid' || $event->getId() !== 'acme_thing_listing') {
return;
}
$definition = $event->getDefinition();
$definition['columns']['owner'] = ['label' => 'Owner', 'type' => 'text', 'position' => 95];
$event->setDefinition($definition);
}
Both events are dispatched from an around plugin on DefinitionResolverInterface::resolve(),
not a before + after pair. A before plugin may only rewrite the arguments it was given
and cannot hand back a definition, so the short-circuit contract is not expressible in that
shape.
FormSaveEvent¶
| Accessor | Type | Notes |
|---|---|---|
getFormId() |
string |
Form definition id. Read only. |
getData() / setData() |
array |
Submitted payload. What _before observers leave is what gets persisted. |
getContext() |
array |
Read only, and only entityId is guaranteed. A save through the generic form controller supplies entityId and storeId, and storeId may be null. SavePipeline::save() adds handlerAlias, which is the empty string when the definition declares no saveHandler. Read every key defensively. |
getSavedEntityId() / setSavedEntityId() |
string\|int\|null |
Populated for _after; null for _before. |
getMessages() / addMessage() |
list<string> |
Messages the response returns to the browser. |
Both fire from SavePipeline, which wraps either a registered save handler, the definition's
repository, or a caller-supplied persistence callback. That third form is how entity-specific
save controllers participate: they own the persistence and still get the events. The
controllers that use it are landing pages, ratings, tax rates, tax rules, agreements and custom
variables, and each of them passes entityId alone, so an observer that expects storeId or
handlerAlias will not find one on those saves.
fieldPrefix is not a context key
The FormSaveEvent docblock lists fieldPrefix alongside the three keys above. No save
path sets it, so reading it always yields nothing. An observer that needs the field prefix
must read settings.fieldPrefix from the form definition instead.
GridDataEvent¶
| Accessor | Type | Notes |
|---|---|---|
getGridId() |
string |
Grid definition id. Read only. |
getConfig() |
array |
Resolved dataSource.config, with the grid's columns merged in. Read only. |
getParams() |
array |
page, pageSize, sort, sortDir, filters, search. Read only. |
getItems() / setItems() |
list<array> |
The rows. setItems() re-indexes. |
getTotalCount() / setTotalCount() |
int |
Clamped at zero on set. |
Use it to enrich rows or mask a column's values. Keep it cheap — it runs on every grid load.
The export endpoint does not fire this event
nebula_grid_data_fetched_after is dispatched from GridDataLoader, which serves the
rendered grid. The grid export controller calls the data provider directly and never goes
through the loader, so an observer that masks or enriches rows for display does not run
for an export. If masking is a security requirement, implement it in the data provider
or the collection, not in this observer.
Security events¶
Two more events exist so an install can forward a sensitive act to its own audit trail without
patching a module. Both break the convention the five above follow. There is no payload object
and no payload class: the dispatch carries flat scalars, read one key at a time as
$observer->getEvent()->getData('subject_id'). Neither is mutable. Both are dispatched after the act is
already persisted and neither caller reads the event back, so an observer can record the act
but cannot veto or alter it.
| Event | Fired by | Fired when | Payload keys |
|---|---|---|---|
nebula_customer_password_set |
Nebula\Theme\Model\Customer\PasswordSetter |
After an administrator has set a customer's password from the admin and the new password is persisted. | customer_id, admin_user_id, admin_username, remote_ip, notified, sessions_cleared, unlocked |
nebula_passkey_credentials_revoked |
Nebula\Passkey\Model\Recovery\CredentialRevoker |
After an administrator has taken another administrator's or a customer's passkeys or second factor away. | subject_type, subject_id, acting_admin_user_id, passkeys_removed, two_step_reset |
subject_type is admin or customer. passkeys_removed is an int and two_step_reset a
bool, so a revocation that only reset the second factor arrives with passkeys_removed at
zero. The password event carries no password material, and its name is also available as the
class constant PasswordSetter::EVENT_PASSWORD_SET.
public function execute(Observer $observer): void
{
$event = $observer->getEvent();
$this->audit->record('nebula.passkeys-revoked', [
'subjectType' => (string) $event->getData('subject_type'),
'subjectId' => (int) $event->getData('subject_id'),
'actingAdminUserId' => (int) $event->getData('acting_admin_user_id'),
'passkeysRemoved' => (int) $event->getData('passkeys_removed'),
'twoStepReset' => (bool) $event->getData('two_step_reset'),
]);
}
notified reports whether the customer's mail actually went out, not whether the administrator
asked for it: a notification that throws is caught, logged and leaves the flag at false while
the password still changed.
Each event is dispatched next to a log line, warning for the revocation and info for the
password, so an install that adds no observer still has a record of the act. Revocation is
gated by
Nebula_Passkey::revoke_others and setting a customer password by
Nebula_Theme::customer_set_password, both in the table at the top of this page. The
screens behind them are in
Customers and security.
Source: code/Nebula/Component/Model/Event/*.php, code/Nebula/Component/Plugin/Definition/LifecycleEventDispatcher.php, code/Nebula/Form/Model/Form/SavePipeline.php, code/Nebula/Grid/Model/GridDataLoader.php, code/Nebula/Grid/Controller/Adminhtml/Grid/Export.php, code/Nebula/Theme/Model/Customer/PasswordSetter.php, code/Nebula/Passkey/Model/Recovery/CredentialRevoker.php.
Cache types¶
| Type code | Label | Holds | Module |
|---|---|---|---|
nebula_definitions |
Nebula Definitions | Compiled grid, form, snippet and component JSON definitions. | Nebula_Component |
nebula_passkey_throttle |
Nebula Passkey Throttle | Passkey rate-limiting counters. Clearing it lifts every active passkey lockout. | Nebula_Passkey |
What invalidates nebula_definitions¶
Every entry is tagged NEBULA_DEFINITIONS, so:
clears the lot. Beyond that:
- The cache key folds in a hash of the enabled-module list. Enabling or disabling any
module changes the hash, so every key misses and nothing stale can be served. There is no
command to run after a
module:enable. - Developer mode never reads or writes it. Definitions resolve live on every request, so a JSON edit shows up on reload with no cache step at all.
- Production mode never reads it either. The compiled manifest at
generated/nebula/definitions.phpis the only legal source, so the invalidation question there is not a cache question — it isbin/magento nebula:compile. See CLI reference. - The resolver also exposes a programmatic clear, which cleans by the same tag.
Source: code/Nebula/Component/etc/cache.xml, code/Nebula/Component/Model/Cache/Type/NebulaDefinitions.php, code/Nebula/Component/Model/Definition/Resolver.php, code/Nebula/Passkey/etc/cache.xml.
Database tables¶
Nineteen tables across six modules. All are declared in etc/db_schema.xml and created by
setup:upgrade.
| Table | Purpose | Module |
|---|---|---|
nebula_grid_view |
Saved grid views, per admin user: name, default flag, sort order and the view's config JSON. | Nebula_Grid |
nebula_media_asset |
The media listing index — one row per file, with path hashes, size, MIME, dimensions, mtime and a browsable flag. | Nebula_Media |
nebula_media_directory |
Per-directory freshness, so an unchanged directory is skipped without being read. | Nebula_Media |
nebula_media_derivative |
Generated derivatives (thumbnails and their state). | Nebula_Media |
nebula_media_alias |
Media path aliases, so a renamed file stays reachable by its old path. | Nebula_Media |
nebula_media_trash |
Soft-deleted media awaiting purge or restore. | Nebula_Media |
nebula_media_path_journal |
Path mutations pending replay by path subscribers. | Nebula_Media |
nebula_email_document |
Email builder JSON documents, keyed to a Magento email_template.template_id, with a content hash. |
Nebula_EmailTemplateEditor |
nebula_merchandiser_preference |
Merchandiser preferences, per admin user, as JSON. | Nebula_Merchandiser |
nebula_landing_page |
Landing pages: title, URL key, body, layout, product selection, campaign window and SEO fields. | Nebula_LandingPage |
nebula_landing_page_store |
Landing page to store view assignment. | Nebula_LandingPage |
nebula_landing_page_hub |
The landing pages a hub page lists, in the order the merchant put them. | Nebula_LandingPage |
admin_passkey_credential |
Passkey credentials for admin users: credential id, public key, user handle, signature counter, transports, AAGUID, label. | Nebula_Passkey |
customer_passkey_credential |
Passkey credentials for storefront customers, scoped to the website they were registered on. | Nebula_Passkey |
admin_totp_secret |
TOTP second-factor secrets for administrators. | Nebula_Passkey |
admin_totp_recovery_code |
Single-use recovery codes for administrator two-factor authentication. | Nebula_Passkey |
customer_totp_secret |
TOTP second-factor secrets for storefront customers. | Nebula_Passkey |
customer_totp_recovery_code |
Single-use recovery codes for customer two-factor authentication. | Nebula_Passkey |
admin_passkey_challenge |
Temporary WebAuthn challenges for both areas. The name is historical — storefront ceremonies use it too. | Nebula_Passkey |
Source: every etc/db_schema.xml under code/.
Cron jobs¶
One job, in the default group.
| Job name | Schedule | Class | What it does |
|---|---|---|---|
nebula_passkey_clean_challenges |
0 * * * * |
Nebula\Passkey\Cron\CleanChallenges |
Deletes expired rows from admin_passkey_challenge. |
Hourly, and deliberately not on the ceremony path. A challenge lives five minutes, so an hour of expired rows is a small table, and doing the delete inline on every login attempt would put a write nobody is waiting for in front of somebody trying to sign in.
Source: code/Nebula/Passkey/etc/crontab.xml.
Configuration written by setup patches¶
Two data patches write on setup:upgrade. Both are one-shot and recorded in
patch_list, so they do not re-apply.
| Config path | Value written | Patch |
|---|---|---|
cms/wysiwyg/enabled |
enabled |
Nebula\Quill\Setup\Patch\Data\ConfigureDefaultEditor |
cms/wysiwyg/editor |
Nebula_Quill/js/wysiwyg/quill-adapter |
Nebula\Quill\Setup\Patch\Data\ConfigureDefaultEditor |
cms/pagebuilder/enabled |
0 |
Nebula\Quill\Setup\Patch\Data\ConfigureDefaultEditor |
All three are written at default scope, so a website- or store-scoped override you have set stays in force.
The second patch writes no configuration but does change the schema: AddMustChangePasswordAttribute adds the customer attribute nebula_must_change_password (int, boolean input,
invisible, not user-defined, default 0). It records that an account is signed in on a
password somebody else chose — set when an administrator sets a password, cleared the moment
the customer chooses their own, and written by nothing else.
Source: code/Nebula/Quill/Setup/Patch/Data/ConfigureDefaultEditor.php, code/Nebula/Theme/Setup/Patch/Data/AddMustChangePasswordAttribute.php.