Skip to content

fix(notifications): resolve only registered notifiables from saved notification settings - #304

Merged
roncodes merged 2 commits into
release/v1.6.69from
fix/notification-notifiable-allowlist
Oct 13, 2026
Merged

roncodes merged 2 commits into
release/v1.6.69from
fix/notification-notifiable-allowlist

Conversation

@roncodes

Copy link
Copy Markdown
Member

needs-human-review: this is a security fix that changes notification dispatch behaviour. This repo has no needs-human-review label, so the flag is here instead.

What

NotificationRegistry::resolveNotifiable() takes each notifiable definition from the company's saved notification_settings. NotificationController::saveSettings stores those settings after checking only that the input is an array, so whoever can save notification settings chooses the string.

  • dynamic:<property>: the registry calls $subject->resolveDynamicNotifiable($property) or $subject->{$property}.
    • Without a resolver, Eloquent's getter treats any method name as a relation. It calls the method on the real subject, then throws because the result is not a Relation.
    • Fleetops Order::resolveDynamicNotifiable() ends in $this->load($property), which calls the method on a fresh instance.
    • Either way, a stored dynamic:dispatch runs Order::dispatch() every time an order notification fires, and that saves and queues OrderDispatched. Any public method with no required arguments is reachable, for example saveInstance. The exception also stops the remaining recipients from being notified.
  • Class definitions: the registry calls app($definition), so the container builds any class named in the settings before the instanceof Model check.

resolveNotifiable() now returns null unless the definition is registered with NotificationRegistry::registerNotifiable(). That covers the core defaults (User, Group, Role, Company) and whatever extensions register; fleetops registers Contact, Driver, Vendor, Fleet, dynamic:customer, dynamic:driver and dynamic:facilitator. A leading \ is ignored. Non-string definitions are skipped too; before, they crashed in Str::startsWith(). A new public helper, NotificationRegistry::isRegisteredNotifiable(), does the check.

Why registration and not a relation check

  • The settings UI already works this way. It only offers registered notifiables (getNotifiablesForCompany() lists static::$notifiables), so every definition saved through the console is already registered. Settings saved through the UI keep working.
  • A relation check is not enough on its own. It would still let settings reach recipients the product never offers, such as dynamic:company or dynamic:createdBy. It also depends on relation methods declaring a Relation return type, which fleetops models don't consistently do yet.
  • The shared helper isn't available. Fleetbase\Support\RequestRelations::relationMethod() (branch fix/request-relation-allowlist) is not merged or pushed, so it can't be reused here.
  • Registration is a smaller rule. An exact allowlist is easier to review than inspecting methods.

The existing tests resolved unregistered dynamic:assignee, dynamic:dispatcher and unregistered fixture classes. They now register what they use, which is what an extension has to do.

Compatibility

  • Extensions: an extension that sends a dynamic: name or notifiable class without registering it will stop notifying that recipient. Fleetops registers everything it uses, and nothing else in the Fleetbase packages calls registerNotifiable() or defines resolveDynamicNotifiable().
  • Saved settings: stored entries that are no longer registered (for example after an extension is uninstalled) are skipped silently instead of erroring.

Validation

  • Tests written first. Four new tests, run against the old code first. Three failed:
    • does not read an unregistered dynamic property from the subject: dispatch() ran on the subject, then LogicException: ...::dispatch must return a relationship instance.
    • does not pass an unregistered dynamic property to the subject resolver: an Order-style load() called dispatch() on a new instance, then Call to a member function addEagerConstraints() on null.
    • does not build an unregistered notifiable class: TypeError from Str::startsWith() on a non-string definition. The class definition in the same test reached app().
    • still resolves a registered dynamic property through the subject resolver passed before and after.
  • Helper test: recognises only registered notifiable definitions covers isRegisteredNotifiable().
  • Results: pest tests/Unit/Support/NotificationRegistryTest.php: 22 passed (58 assertions). Full suite: 2068 passed (12782 assertions; 46 deprecated, 8 warnings).
  • Lint and static analysis: php-cs-fixer --dry-run is clean on the changed files. phpstan (level max) reports nothing in the new code; the file's existing findings are unchanged.

Docs and API reference

  • fleetbase/postman: no change. The internal notifications/save-settings endpoint is not in the collections, and its request and response shapes are unchanged. The Storefront Notifications folder covers customer notification preferences, which is a different feature.
  • fleetbase/fleetbase.io (needs-docs): content/docs/extension-development/recipes/registering-notifications.mdx, section 2, should say that dispatch only resolves registered notifiables, so every dynamic: name an extension's resolveDynamicNotifiable() handles must be passed to registerNotifiable(). content/docs/fleet-ops/settings/notifications.mdx already describes the options as registered notifiables, so it needs no change. This repo has no needs-docs label.

Follow-up (not in this PR)

  • Cross-company lookups. A class definition is still looked up by where($primaryKey, $key) with no company constraint. Settings could name a registered model (for example a User) from another company. Scoping that needs care, because users belong to companies through company_users.
  • Validation on save. saveSettings could drop unregistered definitions when settings are saved, not only when they're resolved.

…ic property or class

A subject with a side-effect dispatch() method is named as dynamic:dispatch,
directly and through a resolveDynamicNotifiable() that loads the property like
fleetops Order does, and a class whose constructor records a call is named as
a definition. The existing dynamic and class tests now register what they use.
…ttings

resolveNotifiable() read the definition from the company's notification_settings,
which saveSettings stores as given. dynamic:<property> reached any public
no-argument method on the subject, through resolveDynamicNotifiable() or the
Eloquent relation getter, so dynamic:dispatch ran Order::dispatch() whenever a
notification fired; a class definition was built with app(). Definitions not
registered with registerNotifiable() are now skipped.
@roncodes
roncodes merged commit df5b4d8 into release/v1.6.69 Oct 13, 2026
2 of 3 checks passed
@roncodes
roncodes deleted the fix/notification-notifiable-allowlist branch October 13, 2026 06:18
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.

1 participant