Skip to content

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:

{
  "id": "nebula_landing_page_listing",
  "acl": "Nebula_LandingPage::manage"
}

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:

bin/magento cache:clean nebula_definitions

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.php is the only legal source, so the invalidation question there is not a cache question — it is bin/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.